diff --git a/go.mod b/go.mod index 12bc1619..21a4812f 100644 --- a/go.mod +++ b/go.mod @@ -30,14 +30,15 @@ require ( github.com/google/go-cmp v0.7.0 github.com/googleapis/go-spanner-cassandra v0.5.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 - github.com/hymkor/go-multiline-ny v0.21.0 + github.com/hymkor/go-multiline-ny v0.22.4 github.com/jessevdk/go-flags v1.6.1 github.com/k0kubun/pp/v3 v3.5.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-runewidth v0.0.16 + github.com/ktr0731/go-fuzzyfinder v0.9.0 + github.com/mattn/go-runewidth v0.0.19 github.com/modelcontextprotocol/go-sdk v0.3.0 github.com/ngicks/go-iterator-helper v0.0.21 - github.com/nyaosorg/go-readline-ny v1.9.1 + github.com/nyaosorg/go-readline-ny v1.14.1 github.com/olekukonko/tablewriter v1.0.9 github.com/samber/lo v1.51.0 github.com/sourcegraph/conc v0.3.0 @@ -47,7 +48,6 @@ require ( github.com/testcontainers/testcontainers-go/modules/gcloud v0.38.0 github.com/vbauerster/mpb/v8 v8.10.2 go.uber.org/zap v1.27.0 - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b golang.org/x/term v0.36.0 google.golang.org/api v0.256.0 google.golang.org/genai v1.21.0 @@ -80,6 +80,7 @@ require ( github.com/apstndb/treeprint v0.0.0-20250529153958-e82576b37da6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -97,6 +98,8 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -115,6 +118,8 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/ktr0731/go-ansisgr v0.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -130,6 +135,8 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/nyaosorg/go-ttyadapter v0.3.0 // indirect github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.0 // indirect @@ -166,6 +173,7 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect diff --git a/go.sum b/go.sum index e31aff59..d4fd8f06 100644 --- a/go.sum +++ b/go.sum @@ -2524,6 +2524,8 @@ github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86c github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudspannerecosystem/memefish v0.6.2 h1:0R6C8KdJLLbL3aYk/rzWrwvE+bPRMqj/2MNlNvAzIPo= github.com/cloudspannerecosystem/memefish v0.6.2/go.mod h1:mVw0xBxy0yOgm990BuR0+nqP8J+yBAAf7N/2uL69rBU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -2629,6 +2631,11 @@ github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= +github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= @@ -2749,6 +2756,8 @@ github.com/google/go-pkcs11 v0.2.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMc github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -2853,8 +2862,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hymkor/go-multiline-ny v0.21.0 h1:dGxPlvL2dcJvT4jOSCfaeVvcSptbf5q+Z5F92Uc8l8g= -github.com/hymkor/go-multiline-ny v0.21.0/go.mod h1:zrkZWF1DAUsILQ1P8+MSrHOkOnndHl2mC6xki8xRrr8= +github.com/hymkor/go-multiline-ny v0.22.4 h1:Ag2rkBpDnr3jp+AHe1CuXSOQ4AQ8D0+gBNVf+BAX+/0= +github.com/hymkor/go-multiline-ny v0.22.4/go.mod h1:v2lqQooHVAO53WICAIbgTn74bQtzsg4tFn29d+7JPwY= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -2898,7 +2907,13 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= +github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= +github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= +github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8= github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= @@ -2920,8 +2935,10 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -2959,8 +2976,12 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ngicks/go-iterator-helper v0.0.21 h1:34dorbGaeL7RgxdymHBqZeL+VLL3hUyAL9QdE5HZrRQ= github.com/ngicks/go-iterator-helper v0.0.21/go.mod h1:g++KxWVGEkOnIhXVvpNNOdn7ON57aOpfu80ccBvPVHI= -github.com/nyaosorg/go-readline-ny v1.9.1 h1:TpQyXaNWtCN0TCRgUbGLMN8rKoPiwh2Xq58UZkivHmk= -github.com/nyaosorg/go-readline-ny v1.9.1/go.mod h1:54AzdC//M5EzTWRdvUHv2ChuYgp58mRrStTlpxiCmT0= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/nyaosorg/go-readline-ny v1.14.1 h1:bWyXpR6jRaCXysx4bnioxk36+YjQ6dypHKMjHnzIXdk= +github.com/nyaosorg/go-readline-ny v1.14.1/go.mod h1:/BDf3/H/AScnvey4LoDws1bjTZDB76EE7uKnW2apoKU= +github.com/nyaosorg/go-ttyadapter v0.3.0 h1:/Y7+rGJ0LEcs+AExevwNmND2VJvvpBmgbMuCbntKq3c= +github.com/nyaosorg/go-ttyadapter v0.3.0/go.mod h1:w6ySb/Y8rpr0uIju4vN/TMRHC/6ayabORHmEVs6d/qE= github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 h1:ZCnkxe9GgWqqBxAk3cIKlQJuaqgOUF/nUtQs8flVTHM= github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= @@ -3003,6 +3024,7 @@ github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOA github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= diff --git a/internal/mycli/cli_readline.go b/internal/mycli/cli_readline.go index 363077a2..c35a4766 100644 --- a/internal/mycli/cli_readline.go +++ b/internal/mycli/cli_readline.go @@ -127,6 +127,19 @@ func initializeMultilineEditor(c *Cli) (*multiline.Editor, History, error) { return nil, nil, err } + if c.SystemVariables.EnableFuzzyFinder { + if keyCode, ok := keys.NameToCode[keys.NormalizeName(c.SystemVariables.FuzzyFinderKey)]; ok { + fuzzyCmd := &fuzzyFinderCommand{cli: c} + err = ed.BindKey(keyCode, fuzzyCmd) + if err != nil { + return nil, nil, err + } + } else { + slog.Warn("unknown key name for CLI_FUZZY_FINDER_KEY, fuzzy finder disabled", + "key", c.SystemVariables.FuzzyFinderKey) + } + } + history, err := setupHistory(ed, c.SystemVariables.HistoryFile) if err != nil { return nil, nil, err diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go new file mode 100644 index 00000000..e01092f0 --- /dev/null +++ b/internal/mycli/fuzzy_finder.go @@ -0,0 +1,165 @@ +// Copyright 2026 apstndb +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mycli + +import ( + "context" + "log/slog" + "regexp" + + "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" +) + +// 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 +// (completion-style behavior). +type fuzzyFinderCommand struct { + editor *multiline.Editor + cli *Cli +} + +func (f *fuzzyFinderCommand) String() string { + return "FUZZY_FINDER" +} + +// SetEditor is called by go-multiline-ny's BindKey to inject the editor reference. +func (f *fuzzyFinderCommand) SetEditor(e *multiline.Editor) { + f.editor = e +} + +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() + result := detectFuzzyContext(input) + if result.contextType == "" { + return readline.CONTINUE + } + + candidates, err := f.fetchCandidates(ctx, result.contextType) + if err != nil { + slog.Debug("fuzzy finder: failed to fetch candidates", "context", result.contextType, "err", err) + return readline.CONTINUE + } + if len(candidates) == 0 { + return readline.CONTINUE + } + + // Terminal handoff: move cursor below editor, run fzf, then restore + rewind := f.editor.GotoEndLine() + + opts := []fuzzyfinder.Option{} + if result.argPrefix != "" { + opts = append(opts, fuzzyfinder.WithQuery(result.argPrefix)) + } + + idx, err := fuzzyfinder.Find(candidates, func(i int) string { + return candidates[i] + }, opts...) + + rewind() + B.RepaintLastLine() + + if err != nil { + // User cancelled (Escape/Ctrl+C) or other error + return readline.CONTINUE + } + + selected := candidates[idx] + + // Replace the argument portion: delete from argStartPos to end of buffer, + // then insert the selected value. + bufLen := len(B.Buffer) + if result.argStartPos < bufLen { + B.Delete(result.argStartPos, bufLen-result.argStartPos) + } + B.Cursor = result.argStartPos + B.InsertAndRepaint(selected) + + 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) +} + +// 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. +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, + } + } + 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: + return f.fetchDatabaseCandidates(ctx) + default: + return nil, nil + } +} + +// fetchDatabaseCandidates lists databases from the current instance. +func (f *fuzzyFinderCommand) fetchDatabaseCandidates(ctx context.Context) ([]string, error) { + session := f.cli.SessionHandler.GetSession() + if session == nil || session.adminClient == nil { + return nil, nil + } + + dbIter := session.adminClient.ListDatabases(ctx, &databasepb.ListDatabasesRequest{ + Parent: session.InstancePath(), + }) + + var databases []string + for db, err := range dbIter.All() { + if err != nil { + return nil, err + } + matched := extractDatabaseRe.FindStringSubmatch(db.GetName()) + if len(matched) > 1 { + databases = append(databases, matched[1]) + } + } + return databases, nil +} diff --git a/internal/mycli/fuzzy_finder_test.go b/internal/mycli/fuzzy_finder_test.go new file mode 100644 index 00000000..993dd541 --- /dev/null +++ b/internal/mycli/fuzzy_finder_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 apstndb +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mycli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectFuzzyContext(t *testing.T) { + tests := []struct { + name string + input string + wantContextType fuzzyContextType + wantArgPrefix string + wantArgStartPos int + }{ + { + name: "USE with trailing space", + input: "USE ", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 4, + }, + { + name: "USE without space", + input: "USE", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 3, + }, + { + 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", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "my-database", + wantArgStartPos: 4, + }, + { + name: "use lowercase", + input: "use db", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "db", + wantArgStartPos: 4, + }, + { + name: "USE with leading space", + input: " USE ", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 6, + }, + { + name: "SELECT query", + input: "SELECT 1", + wantContextType: "", + }, + { + name: "empty input", + input: "", + wantContextType: "", + }, + { + name: "SHOW DATABASES", + input: "SHOW DATABASES", + wantContextType: "", + }, + } + + 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) + } + }) + } +} diff --git a/internal/mycli/main_test.go b/internal/mycli/main_test.go index bb363719..e8c6c8da 100644 --- a/internal/mycli/main_test.go +++ b/internal/mycli/main_test.go @@ -139,7 +139,9 @@ func Test_initializeSystemVariables(t *testing.T) { "p1": lo.Must(memefish.ParseExpr("", "'string_value'")), "p2": lo.Must(memefish.ParseType("", "FLOAT64")), }, - TablePreviewRows: 50, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + TablePreviewRows: 50, }, wantErr: false, }, @@ -173,6 +175,8 @@ func Test_initializeSystemVariables(t *testing.T) { ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, Params: make(map[string]ast.Node), ReadOnlyStaleness: lo.ToPtr(spanner.ReadTimestamp(lo.Must(time.Parse(time.RFC3339Nano, "2023-01-01T00:00:00Z")))), + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -214,6 +218,8 @@ func Test_initializeSystemVariables(t *testing.T) { ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, Params: make(map[string]ast.Node), StatementTimeout: lo.ToPtr(30 * time.Second), + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -239,6 +245,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -264,6 +272,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -288,6 +298,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -332,8 +344,10 @@ func Test_initializeSystemVariables(t *testing.T) { }, }, }, - CLIFormat: enums.DisplayModeTable, - TablePreviewRows: 50, + CLIFormat: enums.DisplayModeTable, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + TablePreviewRows: 50, }, wantErr: false, }, @@ -382,6 +396,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -409,6 +425,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -437,6 +455,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -462,6 +482,8 @@ func Test_initializeSystemVariables(t *testing.T) { CLIFormat: enums.DisplayModeTable, OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -485,6 +507,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "output_full.tmpl", // OutputTemplate: should be parsed from file, hard to compare directly ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -510,6 +534,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -535,6 +561,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -638,6 +666,8 @@ func Test_newSystemVariablesWithDefaults(t *testing.T) { HistoryFile: defaultHistoryFile, VertexAIModel: defaultVertexAIModel, LogLevel: slog.LevelWarn, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, Params: make(map[string]ast.Node), } diff --git a/internal/mycli/system_variables.go b/internal/mycli/system_variables.go index 22749587..0db0628d 100644 --- a/internal/mycli/system_variables.go +++ b/internal/mycli/system_variables.go @@ -150,6 +150,8 @@ type systemVariables struct { AsyncDDL bool // CLI_ASYNC_DDL SkipSystemCommand bool // CLI_SKIP_SYSTEM_COMMAND SkipColumnNames bool // CLI_SKIP_COLUMN_NAMES + EnableFuzzyFinder bool // CLI_ENABLE_FUZZY_FINDER + FuzzyFinderKey string // CLI_FUZZY_FINDER_KEY // Streaming output configuration StreamingMode enums.StreamingMode // CLI_STREAMING @@ -264,6 +266,10 @@ func newSystemVariablesWithDefaults() systemVariables { OutputTemplate: defaultOutputFormat, LogLevel: slog.LevelWarn, + // Interactive defaults + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + // Streaming defaults StreamingMode: enums.StreamingModeAuto, // Default to automatic selection based on format TablePreviewRows: 50, // Default to 50 rows - enough to fit on one screen while prioritizing proper table formatting diff --git a/internal/mycli/var_registry.go b/internal/mycli/var_registry.go index fc3ae90c..15b0702d 100644 --- a/internal/mycli/var_registry.go +++ b/internal/mycli/var_registry.go @@ -207,6 +207,10 @@ func (r *VarRegistry) registerAll() { "Controls whether system commands are disabled.")) r.Register("CLI_SKIP_COLUMN_NAMES", BoolVar(&sv.SkipColumnNames, "A boolean indicating whether to suppress column headers in output. The default is false.")) + r.Register("CLI_ENABLE_FUZZY_FINDER", BoolVar(&sv.EnableFuzzyFinder, + "Enable fuzzy finder. The default is true.")) + r.Register("CLI_FUZZY_FINDER_KEY", StringVar(&sv.FuzzyFinderKey, + "Key binding for fuzzy finder. Uses go-readline-ny key names (e.g., C_T, M_F, F1). The default is C_T (Ctrl+T).")) r.Register("CLI_STREAMING", StreamingModeVar(&sv.StreamingMode, "Controls streaming output mode: AUTO (format-dependent), TRUE (always stream), FALSE (never stream). Default is AUTO."))