🐈

fzf-lua は結構カスタムできるよって話

2024/05/26に公開

はじめに

fzf-lua は Neovim のプラグインで、いわゆる FF (Fuzzy Finder) の一種です。
バックエンドで fzf を使うのが特徴で、非同期通信周りがかなり作り込まれた一品です。
デフォルトで多くのソースを備えた all-in-one 系であり、導入すればすぐに使い始められます。

https://github.com/ibhagwan/fzf-lua

また拡張性に優れた設計をしており、自分のユースケースに合わせて様々な機能を追加することができます。
今回はこの拡張性に焦点を当ててみましょう。

本記事では、こちら[1]の公式 Wiki を参考に、fzf-lua の拡張機能の作り方を簡単に説明します。
さらに、私が実際に利用しているユースケースをいくつかご紹介します。

API

fzf_execfzf_live という 2 つの公開 API が用意されています。

local fzf_lua = require("fzf-lua")
fzf_lua.fzf_exec(...)
fzf_lua.fzf_live(...)

前提知識

fzf-lua は cli の fzf を利用して絞り込みを行います。
そのため、各候補 (entry) は全て単なる文字列でなければなりません (数値は文字列に変換できるのでセーフ)。
標準入力に流しますからね。
色付けも ANSI escape code で実現しており、実体は1つの文字列なのです。
(特に他の FF の内部実装を知っている方は) これを把握しておくと、この後の説明がスムーズに理解できるでしょう。

fzf_exec()

大抵のユースケースには fzf_exec を使います。
なにかしらの候補を渡して fzf で絞り込む、という操作全般を実現できます。

この「大抵」に含まれないのは、live grep などの query に応じて動的に候補が変わるものです。
こちらは fzf_live() の章で説明します。

fzf_exec({contents} [, {opts}])

{contents} は fzf に渡される候補を表現します。
配列、関数、文字列の3パターンありますので、順に説明しましょう。
また {opts} で実行単位毎にオプションを設定できます。

{contents} として配列を使う

文字列の配列をそのまま渡す、最もシンプルな例です。
これは shell で echo "line1\nline2" | fzf とやるのと同じです。

fzf_lua.fzf_exec({ "line1", "line2" })

{contents} として関数を使う

関数を使うことで、もっと複雑なこともできます。

この関数では、引数として与えられる callback 関数に1つずつ entry を渡します。
最後に nil (引数無し) で呼び出すことで、entry の収集が終了したことを通知できます。

fzf_lua.fzf_exec(function(fzf_cb)
  for i = 1, 10 do
    fzf_cb(i)
  end
  fzf_cb() -- EOF
end)

この例だけでは配列とあまり変わらないように見えるかもしれませんね。
関数を使う強力なメリットとして、非同期化を簡単に行えることが挙げられます。

entry の収集に時間がかかる場合、上記の2つのような素直な同期処理では起動が遅くなってしまいます。
その間本体の操作もブロックされ、何もできなくなるのは嬉しくないですね。
これを避けるため、coroutine を使うことができます。

fzf_lua.fzf_exec(function(fzf_cb)
  coroutine.wrap(function()
    for i = 1, 10^9+7 do
      fzf_cb(i, function() coroutine.resume(co) end)
      coroutine.yield()
    end
    fzf_cb() -- EOF
  end)() -- ここの () 忘れがちなので注意
end)

fzf_cb() の第二引数に書いた coroutine.resume は、entry が fzf の標準入力に書き込まれた後に呼ばれます。
つまり処理順は以下のようになります。

  1. fzf_cb が呼ばれるが、制御はまだこちらが握ったまま。次の行へ
  2. coroutine.yield で制御を手放す。
  3. fzf の標準入力に entry を書き込む。
  4. coroutine.resume が呼ばれ、制御が戻ってくる。また次のループへ

注意点を一つ述べておきましょう。
coroutine.yield を使う都合上、ループの中で vim.fn.*vim.api.* は使えません。
これを回避するには vim.schedule を使ってください。

fzf_lua.fzf_exec(function(fzf_cb)
  coroutine.wrap(function()
    for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
      vim.schedule(function()
        local name = vim.api.nvim_buf_get_name(buffer)
        if name == "" then
          name = "[No Name]"
        end
        fzf_cb(buffer .. ":" .. name, function() coroutine.resume(co) end)
      end)
      coroutine.yield()
    end
    fzf_cb() -- EOF
  end)()
end)

{contents} として文字列を使う

最後に文字列です。
これは shell command として解釈されます。

-- これは `ls | fzf` と等価です
fzf_lua.fzf_exec("ls")

shell command の場合は entry の細かな制御が難しいため、opts.fn_transform を使って entry に変換処理をかけることができます。
ls の結果に cwd を追加し、元の出力部分をマゼンタに色付けてみましょう。

fzf_lua.fzf_exec("ls", {
  fn_transform = function(entry)
    return vim.uv.cwd() .. "/" .. fzf_lua.utils.ansi_codes.magenta(entry)
  end
})

fzf_live()

live grep などの一部の機能を実現するためには、fzf_live を使いましょう。
query (現在のプロンプトへの入力) に応じて、動的に候補を変更することができます。

fzf_live({contents} [, {opts}])

この {contents} は query (string) を受け取る関数で、戻り値は fzf_exec における {contents} です。
つまり配列、関数、文字列ですね。
省略して、配列の場合の例だけを載せます。

fzf_lua.fzf_live(function(query)
  local lines = {}
  local n = tonumber(query)
  if n then
    for i = 1, n do
      table.insert(lines, i)
    end
  else
    table.insert(lines, "Invalid number: " .. query)
  end
  return lines
end)

実はこの API、絞り込み処理の差し替えとみなすことができます。
つまり、これを使うと migemo 連携を実装できたりします (rennkei連携 にヒットするようにできる)。

action を登録する

opts.actions を設定することで、任意のアクションを登録できます。
多くのプリセットが用意されているので、基本的な操作であれば作る必要はありません。

fzf_lua.fzf_exec("ls", {
  actions = {
    ["default"] = fzf_lua.actions.file_edit,
    ["ctrl-x"] = fzf_lua.actions.file_split,
    ["ctrl-v"] = fzf_lua.actions.file_vsplit,
  },
})

1から作る場合はここ[2]を参考にしましょう。

各アクションの実体は、関数 fun(selected: string[], opts?: table) です。
selected には、現在選択されている entry の一覧が入っています。
ここさえ理解しおけば、既存のものを wrap して使うこともできますよ。

previewer を付ける

opts.previewer を設定することで、プレビューを付けることができます。

1から作るのは大変ですが、プリセットを継承してカスタマイズすることもできます。[3]
具体例は zenn-cli 連携 をどうぞ。

使用例

vim-mr 連携

vim-mr という、最近使ったファイルの履歴を管理してくれるプラグインがあります。
これを fzf-lua で検索できるようにしました。

lua/rc/plugins/fzf_lua/mr.lua
local fzf_lua = require("fzf-lua")

---@param mode? "mru"|"mrr"|"mrw"
local function mru(mode)
  mode = mode or "mru"
  fzf_lua.fzf_exec(function(cb)
    for _, path in ipairs(vim.fn[("mr#%s#list"):format(mode)]()) do
      local entry = fzf_lua.make_entry.file(path, { file_icons = true, color_icons = true })
      cb(entry)
    end
    cb()
  end, {
    prompt = ("%s> "):format(mode:upper()),
    actions = {
      ["default"] = fzf_lua.actions.file_edit,
      ["ctrl-x"] = fzf_lua.actions.file_split,
      ["ctrl-v"] = fzf_lua.actions.file_vsplit,
    },
    previewer = "builtin",
    fzf_opts = {
      ["--no-sort"] = "",
    },
  })
end

return mru

zenn-cli 連携

zenn-cli を使って記事を管理しているのですが、ファイル名は 318bba82c53a1d.md のようになっています。
これで検索するのは無理なので、タイトルを日本語で検索できるようにしました。
vim-kensaku というプラグインを利用しています。

まあまあ発展的な例なので、ちょっと難しいかも。

lua/rc/plugins/fzf_lua/zenn_dev.lua
local fzf_lua = require("fzf-lua")

local entry2path = {}
local entry_cache = {}

local function read_sync(path)
  local fd = assert(vim.uv.fs_open(path, "r", 292)) -- 0444
  local stat = assert(vim.uv.fs_fstat(fd))
  local buffer = assert(vim.uv.fs_read(fd, stat.size, 0))
  assert(vim.uv.fs_close(fd))
  return buffer
end

local function path2entry(path)
  local content = vim.trim(read_sync(path))
  local title, emoji = "", ""
  local sep_counter = 0
  for line in vim.gsplit(content, "\n") do
    if line:find("^---+$") then
      sep_counter = sep_counter + 1
    elseif line:find("^title%s*:") then
      title = line:gsub("^title%s*:%s*", ""):match('^"(.*)"$')
    elseif line:find("^emoji%s*:") then
      emoji = line:gsub("^emoji%s*:%s*", ""):match('^"(.*)"$')
    end
    if sep_counter >= 2 then
      break
    end
  end
  local padding = 4 - vim.api.nvim_strwidth(emoji)
  local entry = emoji .. (" "):rep(padding) .. title
  return entry
end

local function get_entries(root)
  if #entry_cache > 0 then
    return entry_cache
  end
  for name, _ in vim.fs.dir(root) do
    if name:find("%.md$") then
      local path = vim.fs.joinpath(root, name)
      local entry = path2entry(path)
      entry2path[entry] = path
      if #entry == 4 then
        -- No title
        table.insert(entry_cache, 1, entry)
      else
        table.insert(entry_cache, entry)
      end
    end
  end
  return entry_cache
end

local builtin = require("fzf-lua.previewer.builtin")
local previewer = builtin.buffer_or_file:extend()

function previewer:new(o, opts, fzf_win)
  previewer.super.new(self, o, opts, fzf_win)
  setmetatable(self, previewer)
  return self
end

function previewer:parse_entry(entry_str)
  local path = entry2path[entry_str]
  return { path = path }
end

local function wrap_action(action)
  return function(selected, opts)
    for i, v in ipairs(selected) do
      selected[i] = entry2path[v]
    end
    action(selected, opts)
  end
end

local function zenn(root)
  root = root or vim.fs.normalize("~/zenn/articles")
  fzf_lua.fzf_live(function(q)
    -- 記事を新規に作ることもあるので、クエリが空のときにキャッシュリセット
    if q == "" then
      entry_cache = {}
    end
    local regex = vim.regex(vim.fn["kensaku#query"](q))
    local entries = get_entries(root)
    local filtered = {}
    for _, entry in ipairs(entries) do
      if regex:match_str(entry) then
        table.insert(filtered, entry)
      end
    end
    return filtered
  end, {
    prompt = "Zenn articles> ",
    exec_empty_query = true,
    actions = {
      ["default"] = wrap_action(fzf_lua.actions.file_edit),
      ["ctrl-x"] = wrap_action(fzf_lua.actions.file_split),
      ["ctrl-v"] = wrap_action(fzf_lua.actions.file_vsplit),
    },
    previewer = previewer,
  })
end

return zenn

おわりに

結構端折ってます。
詳細を知りたい方は Wiki の原文[1:1]とソースコードを読んでください。

FF は弄れてなんぼですからね。
Let's have fun!

脚注
  1. https://github.com/ibhagwan/fzf-lua/wiki/Advanced ↩︎ ↩︎

  2. https://github.com/ibhagwan/fzf-lua/blob/main/lua/fzf-lua/actions.lua ↩︎

  3. https://github.com/ibhagwan/fzf-lua/wiki/Advanced#neovim-builtin-preview ↩︎

GitHubで編集を提案

Discussion