fzf-lua は結構カスタムできるよって話
はじめに
fzf-lua は Neovim のプラグインで、いわゆる FF (Fuzzy Finder) の一種です。
バックエンドで fzf を使うのが特徴で、非同期通信周りがかなり作り込まれた一品です。
デフォルトで多くのソースを備えた all-in-one 系であり、導入すればすぐに使い始められます。
また拡張性に優れた設計をしており、自分のユースケースに合わせて様々な機能を追加することができます。
今回はこの拡張性に焦点を当ててみましょう。
本記事では、こちら[1]の公式 Wiki を参考に、fzf-lua の拡張機能の作り方を簡単に説明します。
さらに、私が実際に利用しているユースケースをいくつかご紹介します。
API
fzf_exec
と fzf_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 の標準入力に書き込まれた後に呼ばれます。
つまり処理順は以下のようになります。
-
fzf_cb
が呼ばれるが、制御はまだこちらが握ったまま。次の行へ -
coroutine.yield
で制御を手放す。 - fzf の標準入力に entry を書き込む。
-
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!
Discussion