From 3ba04449540e92e3aedc94442cbed7d22359f9c6 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:50:41 +0900 Subject: [PATCH 1/5] feat(fuzzy-finder): add system variable name completion for SET/SHOW VARIABLE Add fuzzy finder support for SET and SHOW VARIABLE statements. Typing `SET CLI_` or `SHOW VARIABLE ` opens the fuzzy finder with all registered system variable names. Zero API calls needed as variable names come from the in-memory registry. Co-Authored-By: Claude Opus 4.6 --- internal/mycli/fuzzy_finder.go | 50 +++++++++++++++++++--- internal/mycli/fuzzy_finder_test.go | 64 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go index e01092f0..45552e5f 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -17,7 +17,9 @@ package mycli import ( "context" "log/slog" + "maps" "regexp" + "slices" "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" "github.com/hymkor/go-multiline-ny" @@ -100,6 +102,7 @@ type fuzzyContextType = string const ( fuzzyContextDatabase fuzzyContextType = "database" + fuzzyContextVariable fuzzyContextType = "variable" ) // fuzzyContextResult holds the detected context, the argument prefix typed so far, @@ -110,16 +113,23 @@ type fuzzyContextResult struct { 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*))?$`) +var ( + // useContextRe matches "USE" followed by optional whitespace and captures any partial argument. + useContextRe = regexp.MustCompile(`(?i)^\s*USE(\s+(\S*))?$`) + + // setVarContextRe matches "SET " before the "=" sign. + // Captures the partial variable name being typed (must not contain "="). + setVarContextRe = regexp.MustCompile(`(?i)^\s*SET\s+([^\s=]*)$`) + + // showVarContextRe matches "SHOW VARIABLE ". + showVarContextRe = regexp.MustCompile(`(?i)^\s*SHOW\s+VARIABLE\s+(\S*)$`) +) // detectFuzzyContext analyzes the current editor buffer to determine // what kind of fuzzy completion is appropriate. 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. + argPrefix := m[2] argStart := len([]rune(input)) - len([]rune(argPrefix)) return fuzzyContextResult{ contextType: fuzzyContextDatabase, @@ -127,6 +137,24 @@ func detectFuzzyContext(input string) fuzzyContextResult { argStartPos: argStart, } } + if m := setVarContextRe.FindStringSubmatch(input); m != nil { + argPrefix := m[1] + argStart := len([]rune(input)) - len([]rune(argPrefix)) + return fuzzyContextResult{ + contextType: fuzzyContextVariable, + argPrefix: argPrefix, + argStartPos: argStart, + } + } + if m := showVarContextRe.FindStringSubmatch(input); m != nil { + argPrefix := m[1] + argStart := len([]rune(input)) - len([]rune(argPrefix)) + return fuzzyContextResult{ + contextType: fuzzyContextVariable, + argPrefix: argPrefix, + argStartPos: argStart, + } + } return fuzzyContextResult{} } @@ -135,6 +163,8 @@ func (f *fuzzyFinderCommand) fetchCandidates(ctx context.Context, ctxType fuzzyC switch ctxType { case fuzzyContextDatabase: return f.fetchDatabaseCandidates(ctx) + case fuzzyContextVariable: + return f.fetchVariableCandidates(), nil default: return nil, nil } @@ -163,3 +193,13 @@ 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 || sv.Registry == nil { + return nil + } + names := slices.Sorted(maps.Keys(sv.ListVariables())) + return names +} diff --git a/internal/mycli/fuzzy_finder_test.go b/internal/mycli/fuzzy_finder_test.go index 993dd541..9c416d91 100644 --- a/internal/mycli/fuzzy_finder_test.go +++ b/internal/mycli/fuzzy_finder_test.go @@ -85,6 +85,70 @@ func TestDetectFuzzyContext(t *testing.T) { input: "SHOW DATABASES", wantContextType: "", }, + // SET variable context + { + name: "SET with partial name", + input: "SET CLI_", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "CLI_", + wantArgStartPos: 4, + }, + { + name: "SET with no name", + input: "SET ", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "", + wantArgStartPos: 4, + }, + { + name: "SET without space should not match", + input: "SET", + wantContextType: "", + }, + { + name: "set lowercase", + input: "set cli_f", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "cli_f", + wantArgStartPos: 4, + }, + { + name: "SET with = should not match", + input: "SET CLI_FORMAT = TABLE", + wantContextType: "", + }, + { + name: "SET with = no space should not match", + input: "SET CLI_FORMAT=TABLE", + wantContextType: "", + }, + // SHOW VARIABLE context + { + name: "SHOW VARIABLE with partial name", + input: "SHOW VARIABLE CLI_", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "CLI_", + wantArgStartPos: 14, + }, + { + name: "SHOW VARIABLE with no name", + input: "SHOW VARIABLE ", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "", + wantArgStartPos: 14, + }, + { + name: "show variable lowercase", + input: "show variable read", + wantContextType: fuzzyContextVariable, + wantArgPrefix: "read", + wantArgStartPos: 14, + }, + { + name: "SHOW VARIABLES should not match", + input: "SHOW VARIABLES", + wantContextType: "", + }, } for _, tt := range tests { From 9193766c5e15a2c82d2a589914c69c5535f673f3 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:00:29 +0900 Subject: [PATCH 2/5] fix: remove unnecessary Registry nil check in fetchVariableCandidates ListVariables() internally calls ensureRegistry(), making the explicit Registry nil check redundant. The check also prevented the fuzzy finder from working on first use when the registry hadn't been initialized yet. Co-Authored-By: Claude Opus 4.6 --- internal/mycli/fuzzy_finder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go index 45552e5f..bc57b0a2 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -197,7 +197,7 @@ func (f *fuzzyFinderCommand) fetchDatabaseCandidates(ctx context.Context) ([]str // fetchVariableCandidates returns sorted system variable names from the registry. func (f *fuzzyFinderCommand) fetchVariableCandidates() []string { sv := f.cli.SystemVariables - if sv == nil || sv.Registry == nil { + if sv == nil { return nil } names := slices.Sorted(maps.Keys(sv.ListVariables())) From 2537d72a8ad4fcdafac7704e6b82dad123253c7e Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:01:40 +0900 Subject: [PATCH 3/5] feat(fuzzy-finder): add statement name completion and def-based arg completion framework Unify fuzzy completion with clientSideStatementDef so both statement names and arguments are completable from a single source of truth. Two completion modes in one Ctrl+T press: - Argument completion (priority): if input matches a statement with a Completion entry (e.g. "USE ", "SET ", "SHOW COLUMNS FROM "), complete the argument - Statement name completion (fallback): show all client-side statement syntaxes as fuzzy candidates (e.g. typing "SHO" shows all SHOW-prefixed statements) Key changes: - Add fuzzyArgCompletion type and Completion field to clientSideStatementDef - Add Completion entries for USE, SET, SHOW VARIABLE, SHOW COLUMNS FROM, SHOW INDEX FROM, TRUNCATE TABLE, DROP DATABASE - Add fetchTableCandidates querying INFORMATION_SCHEMA.TABLES - Rewrite detectFuzzyContext to iterate defs instead of hardcoded regexes - Add extractFixedPrefix to derive insert text from syntax strings - Use cursor position (not full buffer) for context detection - Show loading indicator with timeout for network-dependent completions Co-Authored-By: Claude Opus 4.6 --- internal/mycli/client_side_statement_def.go | 50 +++ internal/mycli/fuzzy_finder.go | 258 ++++++++++--- internal/mycli/fuzzy_finder_test.go | 384 +++++++++++++++----- 3 files changed, 539 insertions(+), 153 deletions(-) diff --git a/internal/mycli/client_side_statement_def.go b/internal/mycli/client_side_statement_def.go index f729d261..c44ae600 100644 --- a/internal/mycli/client_side_statement_def.go +++ b/internal/mycli/client_side_statement_def.go @@ -33,6 +33,24 @@ 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 +) + +// 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 +62,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 +103,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 +133,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 +190,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 +207,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 +395,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 +783,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 +823,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 bc57b0a2..29a58451 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -16,17 +16,24 @@ package mycli import ( "context" + "fmt" "log/slog" "maps" - "regexp" "slices" + "strings" + "time" + "unicode" + "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 @@ -46,17 +53,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 { @@ -83,7 +89,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. @@ -97,74 +110,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" - fuzzyContextVariable fuzzyContextType = "variable" -) - // 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) } -var ( - // useContextRe matches "USE" followed by optional whitespace and captures any partial argument. - useContextRe = regexp.MustCompile(`(?i)^\s*USE(\s+(\S*))?$`) - - // setVarContextRe matches "SET " before the "=" sign. - // Captures the partial variable name being typed (must not contain "="). - setVarContextRe = regexp.MustCompile(`(?i)^\s*SET\s+([^\s=]*)$`) - - // showVarContextRe matches "SHOW VARIABLE ". - showVarContextRe = regexp.MustCompile(`(?i)^\s*SHOW\s+VARIABLE\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] - 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 { + m := comp.PrefixPattern.FindStringSubmatch(input) + if m == nil { + continue + } + argPrefix := m[1] + argStart := len([]rune(input)) - len([]rune(argPrefix)) + return fuzzyContextResult{ + completionType: comp.CompletionType, + argPrefix: argPrefix, + argStartPos: argStart, + } } } - if m := setVarContextRe.FindStringSubmatch(input); m != nil { - argPrefix := m[1] - argStart := len([]rune(input)) - len([]rune(argPrefix)) - return fuzzyContextResult{ - contextType: fuzzyContextVariable, - 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, + }) } } - if m := showVarContextRe.FindStringSubmatch(input); m != nil { - argPrefix := m[1] - argStart := len([]rune(input)) - len([]rune(argPrefix)) - return fuzzyContextResult{ - contextType: fuzzyContextVariable, - argPrefix: argPrefix, - argStartPos: argStart, + 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 } - return fuzzyContextResult{} } -// fetchCandidates returns completion candidates for the given context type. -func (f *fuzzyFinderCommand) fetchCandidates(ctx context.Context, ctxType fuzzyContextType) ([]string, error) { - switch ctxType { - case fuzzyContextDatabase: +// 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) + } + rewind() + + return candidates, err +} + +// 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 fuzzyContextVariable: + case fuzzyCompleteVariable: return f.fetchVariableCandidates(), nil + case fuzzyCompleteTable: + return f.fetchTableCandidates(ctx) default: return nil, nil } @@ -203,3 +302,42 @@ func (f *fuzzyFinderCommand) fetchVariableCandidates() []string { 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 9c416d91..9bacdecf 100644 --- a/internal/mycli/fuzzy_finder_test.go +++ b/internal/mycli/fuzzy_finder_test.go @@ -22,143 +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, }, - // SET variable context { - name: "SET with partial name", - input: "SET CLI_", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "CLI_", - wantArgStartPos: 4, + name: "SHOW VARIABLE with no name", + input: "SHOW VARIABLE ", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "", + wantArgStartPos: 14, }, { - name: "SET with no name", - input: "SET ", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "", - wantArgStartPos: 4, + name: "show variable lowercase", + input: "show variable read", + wantCompletionType: fuzzyCompleteVariable, + wantArgPrefix: "read", + wantArgStartPos: 14, }, + // Argument completion: SHOW COLUMNS FROM → table { - name: "SET without space should not match", - input: "SET", - wantContextType: "", + name: "SHOW COLUMNS FROM with trailing space", + input: "SHOW COLUMNS FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 18, }, { - name: "set lowercase", - input: "set cli_f", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "cli_f", - wantArgStartPos: 4, + 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: "SET with = should not match", - input: "SET CLI_FORMAT = TABLE", - wantContextType: "", + name: "SHOW INDEX FROM with trailing space", + input: "SHOW INDEX FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 16, }, { - name: "SET with = no space should not match", - input: "SET CLI_FORMAT=TABLE", - wantContextType: "", + name: "SHOW INDEXES FROM with partial table", + input: "SHOW INDEXES FROM Al", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "Al", + wantArgStartPos: 18, }, - // SHOW VARIABLE context { - name: "SHOW VARIABLE with partial name", - input: "SHOW VARIABLE CLI_", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "CLI_", - wantArgStartPos: 14, + name: "SHOW KEYS FROM", + input: "SHOW KEYS FROM ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 15, }, + // Argument completion: TRUNCATE TABLE → table { - name: "SHOW VARIABLE with no name", - input: "SHOW VARIABLE ", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "", - wantArgStartPos: 14, + name: "TRUNCATE TABLE with trailing space", + input: "TRUNCATE TABLE ", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "", + wantArgStartPos: 15, }, { - name: "show variable lowercase", - input: "show variable read", - wantContextType: fuzzyContextVariable, - wantArgPrefix: "read", - wantArgStartPos: 14, + name: "TRUNCATE TABLE with partial table", + input: "TRUNCATE TABLE Sin", + wantCompletionType: fuzzyCompleteTable, + wantArgPrefix: "Sin", + wantArgStartPos: 15, }, + // Argument completion: DROP DATABASE → database { - name: "SHOW VARIABLES should not match", - input: "SHOW VARIABLES", - wantContextType: "", + 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]) + } +} From 50208d28b15275d7c26ebc3a3b92289742d166f3 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:13:44 +0900 Subject: [PATCH 4/5] refactor: add String() method to fuzzyCompletionType for debug logging Co-Authored-By: Claude Opus 4.6 --- internal/mycli/client_side_statement_def.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/mycli/client_side_statement_def.go b/internal/mycli/client_side_statement_def.go index c44ae600..e70b48ce 100644 --- a/internal/mycli/client_side_statement_def.go +++ b/internal/mycli/client_side_statement_def.go @@ -42,6 +42,19 @@ const ( fuzzyCompleteTable ) +func (t fuzzyCompletionType) String() string { + switch t { + case fuzzyCompleteDatabase: + return "database" + case fuzzyCompleteVariable: + return "variable" + case fuzzyCompleteTable: + return "table" + default: + return "unknown" + } +} + // 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. From 3c81624d4ce9536f5b8a7fbd390d470975932dc6 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:34:21 +0900 Subject: [PATCH 5/5] refactor: use FindStringSubmatchIndex and improve String() default - Use FindStringSubmatchIndex for argStart calculation in detectFuzzyContext, replacing the implicit suffix convention with direct byte position from the regex engine. - Improve fuzzyCompletionType.String() default case to include the numeric value for easier debugging of unhandled types. Co-Authored-By: Claude Opus 4.6 --- internal/mycli/client_side_statement_def.go | 2 +- internal/mycli/fuzzy_finder.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/mycli/client_side_statement_def.go b/internal/mycli/client_side_statement_def.go index e70b48ce..91039b5f 100644 --- a/internal/mycli/client_side_statement_def.go +++ b/internal/mycli/client_side_statement_def.go @@ -51,7 +51,7 @@ func (t fuzzyCompletionType) String() string { case fuzzyCompleteTable: return "table" default: - return "unknown" + return fmt.Sprintf("unhandled fuzzyCompletionType: %d", t) } } diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go index 29a58451..61245b3f 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -23,6 +23,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" "cloud.google.com/go/spanner" "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" @@ -125,12 +126,12 @@ func detectFuzzyContext(input string) fuzzyContextResult { // Try argument completion first: iterate all defs with Completion entries. for _, def := range clientSideStatementDefs { for _, comp := range def.Completion { - m := comp.PrefixPattern.FindStringSubmatch(input) - if m == nil { + loc := comp.PrefixPattern.FindStringSubmatchIndex(input) + if loc == nil { continue } - argPrefix := m[1] - argStart := len([]rune(input)) - len([]rune(argPrefix)) + argPrefix := input[loc[2]:loc[3]] + argStart := utf8.RuneCountInString(input[:loc[2]]) return fuzzyContextResult{ completionType: comp.CompletionType, argPrefix: argPrefix,