Fortes


Using FZF in a Deno Script

Piping to FZF, with extra steps

Hermit Crab

Hermit Crab Osa Peninsula, Costa Rica

At some point, all of my Bash scripts become incomprehensible enough that I’m forced to re-write them in another language. Usually, I’d switch into Python (which I’m also terrible at) since it’s broadly available or trivial to install. Lately, I’ve started using Deno instead, since it means I can use TypeScript which I actually (kinda) know. Deno is is a little more work to install, but otherwise has some nice ergonomics for CLI tools for a few reasons:

  • Deno supports TypeScript natively, so you get type safety without needing to compile the code
  • No need to install or run a package manager like pip or npm to install dependencies, deno takes care of it automatically when running the script
  • The Standard Library has a solid command line argument parser
  • Startup time is faster than Node, so it’s not a huge pain to run a script for a one-off task

As you know, I’m a big fan of FZF, and I use it in my bash scripts all the time. There’s a library that mimics the fuzzy-find algorithim, but it’s mostly meant for the browser so it doesn’t handle terminal input and display (especially nice touches like honoring readline keyboard shortcuts).

Fortunately, Deno has some nice APIs for spawning external commands, so we can just use the real fzf binary instead. Here’s some code I’ve used in a few places in order to spawn FZF in order to do some interactive selection:

export interface FzfSelection {
  /** Value displayed to the user, must not contain newlines */
  display: string;
  /** Unique identifier for this selection, must not contain spaces */
  id: string;
}

interface FzfOptions {
  /** Allow multiple selections */
  allowMultiple?: boolean;
  /** Automatically select if only one item */
  autoSelectSingle?: boolean;
}

const BASE_FZF_ARGUMENTS = [
  "--cycle",
  "--no-sort",
  "--bind",
  "ctrl-a:select-all,ctrl-d:deselect-all",
  "--with-nth",
  "2..",
];

/**
 * Let the user make selections interactively via FZF
 */
export async function getUserSelections<T extends FzfSelection>(
  items: T[],
  { allowMultiple = false, autoSelectSingle = false }: FzfOptions = {},
): Promise<T[]> {
  if (!items.length || (items.length === 1 && autoSelectSingle)) {
    return items;
  }

  const fzf = new Deno.Command(`fzf`, {
    args: [...BASE_FZF_ARGUMENTS, allowMultiple ? "--multi" : ""].filter(
      Boolean,
    ),
    stdin: "piped",
    stdout: "piped",
    stderr: "inherit",
  });

  const process = fzf.spawn();

  const choiceList = items
    .map((line) => `${line.id} ${line.display}`)
    .join("\n");

  // Write the choices to stdin
  const encoder = new TextEncoder();
  const writer = process.stdin.getWriter();
  // Must use trailing newline, otherwise last item won't appear
  writer.write(encoder.encode(choiceList + "\n"));

  // User can now interact with fzf to filter and select

  // This will now wait until the process exits
  const { code, success, stdout } = await process.output();
  if (!success) {
    switch (code) {
      case 1: // No match
      case 130: // Command terminated by user
        return [];
      default:
        throw new Error(`fzf exited with status ${code}`);
    }
  }

  const lines = new TextDecoder().decode(stdout).trim();

  const selectedIds = lines
    .split("\n")
    .map((line) => line.split(" ")[0]);

  return items.filter((item) => selectedIds.includes(item.id));
}

Here’s an example of how you’d use it:

const KombuchaFlavors = [
  {id: '1', display: 'Gingerade ($3.99)'},
  {id: '2', display: 'Multi-Green ($3.49)'},
  {id: '3', display: 'Guava Goddess ($3.99)'},
  {id: '4', display: 'Island Bliss ($2.99)'},
  {id: '5', display: 'Strawberry Serenity ($4.99)'},
];

const flavorsToOrder = await getUserSelections(KombuchaFlavors, {allowMultiple: true});

console.log("You ordered: ", flavorsToOrder.map((flavor) => flavor.display).join(", "));

And here it is in action:

Deno script using FZF interactively

Note that there are a few edge cases that aren’t handled here: specifically, you need to make sure the id does not have any spaces, and there cannot be any newlines in the display value. If you want to get fancy, you could stream the options into FZF as they’re generated, but that will be left as an exercise to the reader.