Compiling a TUI fuzzy-finder into a Go program
| updated
An experiment to compile a fuzzy-finder into a Go program,
rather than launching an fzf process and communicating with it via stdin/stdout
The intended use case is to replace the Python-based ebk-find package,
which sends a list of e-books to fzf to allow the user to interactively select one.
See also: the Motivation section of the go-fuzzyfinder readme
Sample program
Let’s use ktr0731/go-fuzzyfinder by Taro Aoki. We pass in a list of paths, display their basenames to the user, and the index of the selected item gets used to print out the full path.
We also need to use the WithQuery and WithSelectOne options
so that we can test the program non-interactively:
if we pass a specific enough query,
Find will return the only match without waiting for user input.
package main
import (
"flag"
"fmt"
"path/filepath"
"github.com/ktr0731/go-fuzzyfinder"
)
func main() {
library := flag.String("l", ".", "path to library")
query := flag.String("q", "", "query to filter matches")
flag.Parse()
books, _ := list(*library)
i, _ := find(books, *query)
fmt.Print(books[i])
}
func list(library string) ([]string, error) {
return filepath.Glob(filepath.Join(library, "*/*/*.epub"))
}
func find(books []string, query string) (int, error) {
display := func(i int) string {
return filepath.Base(books[i])
}
return fuzzyfinder.Find(
books,
display,
fuzzyfinder.WithQuery(query),
fuzzyfinder.WithSelectOne(),
)
}
Running it
Set up the workspace like this:
mkdir /tmp/test
cd /tmp/test
go mod init test
go get github.com/ktr0731/go-fuzzyfinder@v0.8.0
Save the source code above as /tmp/test/main.go.
Then run it with:
go mod tidy
go build
./test -l ~/Books -q go
Observations
The call to the fuzzy finder cannot easily be tested by itself:
a test that calls find directly doesn’t run when called from within vim
(“terminal not cursor addressable”),
and when go test is run in the terminal, it succeeds but the terminal gets messed up
(need to call reset to fix it).
But we can test it by calling go run . in the test.
If the -q argument is specific enough, the test will run non-interactively.
func TestMain(t *testing.T) {
cmd := exec.Command(
"go", "run", ".",
"-l", "/home/me/Books",
"-q", "go web chang")
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
}
However, this causes the terminal to flash,
even if the query is specific enough to only return a single result.
This must mean that the ‘Query’ and ‘SelectOne’ options don’t short-circuit
the launching of the TUI in fuzzyfinder.Find.
I’m not sure what would happen if you tried to run the tests without a tty,
e.g. in a headless test runner.
Using FZF
FZF isn’t really designed to be used a library, so it isn’t well documented, but it is possible to use it like this:
package main
import (
"fmt"
"log"
"strings"
fzf "github.com/junegunn/fzf/src"
)
func main() {
useDefaults := true // $FZF_DEFAULT_OPTS_FILE and $FZF_DEFAULT_OPTS
flags := strings.Fields("--exit-0 --select-1 --query l")
options, err := fzf.ParseOptions(useDefaults, flags)
check(err)
inputs := make(chan string)
outputs := make(chan string)
options.Input = inputs
options.Output = outputs
data := []string{"alpha", "beta", "gamma", "delta"}
go func() {
for _, s := range data {
inputs <- s
}
close(inputs)
}()
go func() {
for s := range outputs {
fmt.Println("selected:", s)
}
}()
_, err = fzf.Run(options)
check(err)
}
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
Update [2025-02-05]
I ultimately ended up calling fzy as a subprocess in ebk-select.