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.