4.2K Views
March 21, 25
スライド概要
DeNA.vim @ TechCon 2025 での登壇資料です。
https://dena.connpass.com/event/339233/
Vimmer です。
Neovim での プラグイン開発の今 ある程度複雑なプラグインを開発する際に 便利な Tips とその実例を紹介
自己紹介 陣内 靖(じんのうち やすし) 株式会社ディー・エヌ・エー所属 バックエンドエンジニア @delphinus @delphinus35 telescope.nvim ちょっとワカル
プラグイン、 書いてますか
plenary.nvim って 知ってますか
plenary.nvim とは ▸ nvim-telescope/telescope.nvim を作者(TJ 氏)が作る際、 一緒に作られた便利な関数を集めたユーティリティ集です。 ▸ レポジトリ: nvim-lua/plenary.nvim ▹ All the lua functions I don't want to write twice. ▪ 「二度は書きたくない関数を集めたもの」
plenary.nvim のモジュールたち ▸ 現状で 21 種ものモジュールがあります。代表的なものはこちら。 ▹ plenary.async ▹ plenary.path ▹ plenary.strings ▹ 今回は紹介できませんが、↓これらも便利です。 ▪ ▸ plenary.curl, plenary.log, plenary.json, plenary.window 最近の Neovim に(機能として)吸収されたもの。 ▹ plenary.job → vim.system ▹ plenary.scandir → vim.fs.dir ▹ plenary.iterators, plenary.functional → vim.iter
plenary.path モジュール ▸ ファイルパスを簡単に扱えるモジュール。 ▸ vim.fs(後述)も同種の機能を持ちますこちらの方が高機能です。 local Path = require "plenary.path" local p = Path.new "/full/path/to/hoge.txt" assert(p:parent(), "/full/path/to") assert(p:is_file(), true)
plenary.path モジュール(その 2)
▸
Python の pathlib に似ている。
local p = Path.new "/path/to/new/file.txt"
assert(p:exists(), false)
p:write("ほげ\nふが\n", "w", tonumber("0666", 8))
local lines = p:read_lines() --> { "ほげ", "ふが" }
▸
ファイルとパスの操作はこれ一つで何でもできます。
plenary.strings モジュール ▸ Lua はマルチバイト文字列が苦手なのでそこを補完するモジュール。 local strings = require "plenary.strings" local len = strings.strdisplaywidth "アイウエオ" --> 10 -- ↑ strdisplaywidth() に相当 local chunk = strings.strcharpart("アイウエオ", 2, 2) --> "ウエ" -- ↑ strcharpart() に相当 ▸ Vim Script の関数が呼べない場所で使うのがそもそもの目的です。 ▹ これにどういう場合が該当するかは後述。
plenary.strings モジュール(その 2) local part = strings.truncate("アイウエオ", 9) --> "アイウエ…" -- ↑ 指定した文字数に切り詰める local aligned = strings.align_str("アイウ", 8) --> "アイウ " -- ↑ 左寄せ、右寄せにする local dedented = strings.dedent(multi_line_texts, 4) -- ↑ Python の textwrap.dedent() のようなやつ -- >=0.11 なら vim.text.indent() ▸ 'ambiwidth' や setcellwidths() や 'tabstop' の設定を尊重します。 ▹ 要するに、画面上で見えてるように動きます。
関数が呼べない場所? ▸ Lua からは Neovim の機能を呼べない場所があります。 ▹ vim.fn.*, vim.cmd.*, vim.api.nvim_* が該当します。 ▹ :h api-fast ▹ :h E5560 vim.uv は luv の機能にアクセスします。 luv は libuv の Lua バインディングです。 local timer = assert(vim.uv.new_timer()) timer:start(1000, 0, function() -- 1 秒経ったら実行する -- :echo 12345 と同じ vim.cmd.echo(12345) -- ここで E5560 エラー end) JavaScript の setTimeout() みたいなもの
関数が呼べない場所?(その 2) ▸ コルーチンの中や、libuv の関数のコールバック部分が該当します。 ▸ E5560 エラーを避けるには ▸ ▹ vim.schedule() を使う。 ▹ 同等の機能をもった別の関数を使う。 の 2 つの方法があります。
vim.schedule() / vim.schedule_wrap() local timer = assert(vim.uv.new_timer()) timer:start(1000, 0, function() vim.schedule(function() vim.cmd.echo(12345) -- 今度はエラーにならない end) -- vim.schedule_wrap(vim.cmd.echo)(12345) end) ▸ 次のイベントループまで待ってから実行されます。
コールバックの中でも使える関数で代用する local timer = assert(vim.uv.new_timer()) timer:start(1000, 0, function() print(12345) -- :echo は print() と結果は一緒。 end) ▸ この方が即時に実行されるので良い。 ▹ 同等の関数が常にあるとは限らないけれど……。
再び plenary.strings ▸ plenary.strings の各関数はコルーチンやコールバックの中でも使えます。 local timer = assert(vim.uv.new_timer()) timer:start(1000, 0, function() local strings = require "plenary.strings" local len = strings.strdisplaywidth "アイウエオ" -- ここで vim.fn.strdisplaywidth() を使うとエラーになる。 end) ▸ telescope.nvim で画面を描画するコルーチンで使うために作られました。 ▹ というかこのモジュールは僕が書きました。
plenary.async モジュール ▸ 「コルーチン」という言葉が度々出てきました。 ▹ コルーチン - Wikipedia ▸ Lua で非同期処理を書く時はコルーチンを使います。 ▸ しかしこのコルーチン、JavaScript の async/await のような モダンな仕組みに慣れていると記法が分かりにくいです。 ▸ plenary.async はそれをすっきり整理してくれます。 ▸ 詳しくは以前記事に書きましたのでここでは例示に留めます。 ▹ plenary.nvim による非同期処理 #Lua - Qiita
plenary.async モジュール(その 2) -- これが…… local function read_file(path, callback) vim.uv.fs_open(path, "r", tonumber("0666", 8), function(err, fd) assert(not err, err) vim.uv.fs_fstat(fd, function(err, stat) assert(not err, err) vim.uv.fs_read(fd, stat.size, 0, function(err, data) assert(not err, err) vim.uv.fs_close(fd, function(err) assert(not err, err) callback(data) end) end) end) コールバック地獄が…… end) end -- plenary.async を使うとこうなる local async = require "plenary.async" local function read_file(path) local err, fd = async.uv.fs_open(path, "r", tonumber("0666", 8)) assert(not err, err) local stat err, stat = async.uv.fs_fstat(fd) assert(not err, err) local data err, data = async.uv.fs_read(fd, stat.size, 0) assert(not err, err) err = async.uv.fs_close(fd) assert(not err, err) return data end すっきり!
まとめ(plenary.nvim の今後) ▸ plenary.nvim には telescope.nvim だけでなく、多くのプラグインが 依存していますので、あなたの環境にも知らない内に入っているでしょう。 ▸ Neovim は今後 plenary.nvim で実現しているような機能を標準搭載してい く方針です。また、telescope.nvim 自体も、plenary.nvim への依存を無く す issue が立っています。 ▸ plenary.nvim も長期的には役目を終えるでしょうが、 今後暫くは第一線で活躍してくれるでしょう。 ▹ 今回紹介した plenary.strings も、以前と状況が変わり、現在では Neovim 標準の関数を 駆使すれば代替できるようになりました(求む PR)。
0.10 時代の Neovim Lua
標準モジュールも進化しています ▸ 0.10 時代の Neovim Lua #neovim - Qiita ▸ ↑ 以前書いた記事。vim.system, vim.iter について書いています。 ▹ ▸ 以降で例示します。 昔は plenary.job などを使わないと実現できなかったことが Neovim Lua の標準機能で可能になりました。
vim.system
-- ls -l を非同期に実行して結果を表示する
vim.system({ "ls", "-l" }, { text = true }, function(job)
if job.code == 0 then
print(job.stdout)
else
print(("code: %d, stderr: %s"):format(job.code, job.stderr))
end
end)
-- local job = vim.system({ "ls", "-l" }, { text = true }):wait()
vim.iter
-- ↓ キーにファイル名、値に出現回数の入ったハッシュが返ります。
local seen_map = vim
.iter(vim.fs.dir(".", { depth = 100 })) -- エントリーを再帰的に列挙します
:take(100000)
-- vim.fs.dir については後述
:filter(function(_, type) return type == "file" end)
:map(function(name, _) return vim.fs.basename(name) end)
:fold({}, function(a, basename) -- :reduce() でも良い
a[basename] = (a[basename] or 0) + 1
return a
end)
vim.fs ▸ ファイルやパスの操作を行うモジュールです。plenary.path に似ています。 ▸ 0.10 では Windows で上手く動かないです(0.11 で直るかも)。 local filename = "/private/etc/hosts" local dir = vim.fs.dirname(filename) --> "/private/etc" local name = vim.fs.basename(filename) --> "hosts" local norm = vim.fs.normalize "/ho/ge/..///../fuga" --> "/fuga" -- 以下は >= 0.11 local rel = vim.fs.relpath("/private", filename) --> "etc/hosts" local abs = vim.fs.abspath(rel) --> "/path/to/cwd/etc/hosts"
vim.fs.find
▸
起点となるディレクトリから、配下、あるいは親に遡って
指定した名前を探します。
▸
package.json など、CLI ツールの設定ファイルを探すのに便利です。
-- 遡って設定ファイルを探し、それでコマンドを実行する
local found = vim.fs.find(
{ "cspell.json", "cspell.yaml", "cspell.yml" },
{ upward = true, path = "/path/to/dir" }
)
vim.system({ "cspell", "-c", found, filename }):wait()
vim.fs.root
▸
ファイル名かバッファーを指定すると、そのパスを遡って指定した
ファイル・ディレクトリを直下に含むディレクトリを得ます。
-- 基本の使い方。Git のレポジトリートップを得ます。
local repo_dir = vim.fs.root("/path/to/some/file.txt", { ".git" })
-- バッファー番号を指定するのもいい
vim.fs.root(0, { "package.json", ".eslintrc.yaml" })
-- 関数も指定できる
vim.fs.root(0, function(name, path) return name:match "foo" end)
vim.fs.root(その 2) ▸ レポジトリーのトップを知るのに便利です。 airblade/vim-rooter や ahmedkhalf/project.nvim の代わりになります。 ▹ -- vim-rooter 相当の機能がこれだけで実装できる vim.api.nvim_create_autocmd("BufWinEnter", { callback = function(ev) local root = vim.fs.root(ev.buf, ".git") if root then vim.cmd.lcd(root) end end, })
vim.fs.dir ▸ 指定したディレクトリ配下を再帰的に列挙するイテレーターを返します。 -- /some/path 配下の全てのファイルを列挙する for name, type in vim.fs.dir("/some/path", { depth = 100 }) do if type == "file" then print(name) end end ▸ イテレーターは「実行する度に候補を返す関数」です。 ▸ vim.fs.dir はコルーチンを使って非同期に候補を列挙しますので (適切に利用すれば)ユーザーの操作をブロックしません。
まとめ ▸ 0.10 になって Lua 標準モジュールにはかなりの機能が追加されました。 ▸ 他にも以下のような機能が議論されています。 ▸ ▹ HTTP クライアント: neovim/neovim#17820 ▹ 並行処理とパイプライン: #19624 ▹ 画像処理: #30889 ▹ 任意のフォントサイズを利用可能にする: #32539 一早く試したい人は Neovim HEAD をインストールして :h news.txt をチェックしてみましょう。
実践編: telescope-frecency .nvim で見る Neovim Lua
telescope-frecency.nvim とは
telescope-frecency.nvim とは(その 2) ▸ レポジトリ: nvim-telescope/telescope-frecency.nvim ▸ 賢い :Telescope oldfiles(:h oldfiles)。 ▸ 過去に開いた累計の回数、最近開いた頻度などから、 今までに開いたファイルを並び替えて表示します。 ▸ オプションで以下のような機能も。 ▹ すでに開いているファイルは上位に表示。 ▹ 指定したディレクトリ配下のみに絞り込む。 ▹ 一度も開いたことの無いファイルも列挙します。
telescope-frecency.nvim とは(その 3) ▸ ▸ ソートには Frecency アルゴリズムを使っています。 ▹ v1: Frecency algorithm - Mozilla | MDN ▹ v2: User:Jesse/NewFrecency - MozillaWiki 類似の Picker としては(多分)最古参で、 以下のようなフォロワーが生まれています。 ▹ danielfalk/smart-open.nvim ▹ prochri/telescope-all-recent.nvim ▹ folke/snacks.nvim の picker
telescope.nvim との出会い ▸ telescope.nvim 自体は 2020/7 に開発が始まっていますが、話題になり初 めたのは 2020 年末だったと思います。 ▸ 最初僕がした PR は 2021/1。 ▹ telescope.nvim#397 ▹ 「Thanks」って言われると やっぱ嬉しいですね。
nvim-telescope organization に追加しまくる期 ▸ extension(拡張機能)を作りまくってみんなに使って欲しくなったので、 「公開できる場所はないの?」と尋ねる。 ▸ 「この org でいいよ。ついでに admin 権限もあげるね」 ▸ 勢いのあるプロダクトに コントリビュートしまくると いいことあるよ。 ▹ 今なら snacks.nvim とか?
telescope-frecency.nvim を改善したい! ▸ telescope-frecency.nvim 自体は僕の作ったものではないのですが、 どうも 2022 年に入ってから開発が滞りがち。 ▸ 途中から(勝手に)引き継いで開発し続けています。 ▸ 引き継いだ時点で以下のような不満がありました。 ▹ 動作が外部ライブラリ(SQLite)に依存している。 ▹ DB に無いファイルの列挙が遅い。 ▹ telescope.nvim の最新の変更に追随していない。
SQLite への依存を無くす ▸ ビルドに失敗する例が(特に Windows で)しばしばありました。 ▸ 元々やりたいことに複雑なクエリは必要無いため、 単純にデータをファイルに保存する実装にしました。 -- DB に保存する処理はシンプルにこれだけ local data = encode(tbl) -- 何らかの方法でエンコードする local _, fd = async.uv.fs_open(file, "w", 420) async.uv.fs_write(fd, data) async.uv.fs_close(fd)
SQLite への依存を無くす(その 2) ▸ plenary.async を使うので、ストレージにアクセスする間 ユーザーの操作をブロックしません。 -- DB から読み込む処理はこう local _, stat = async.uv.fs_stat(file) local _, fd = async.uv.fs_open(file, "r", 420) -- 0o0644 local _, data = async.uv.fs_read(fd, stat.size) async.uv.fs_close(fd) local decoded = decode(data) -- 何らかの方法でデコードする
SQLite への依存を無くす(その 3) ▸ エンコード・デコードは Lua の中間表現を使っています。 -- エンコード local f = assert(load("return " .. vim.inspect(tbl))) local encoded = string.dump(f) -- デコード local f = vim.F.npcall(loadstring, data or "") local decoded = f and vim.F.npcall(f) ▸ Lua で eval 関数を作る際に、なぜ assert を使うのか #Lua - Qiita
DB アクセスの排他制御 ▸ 複数の Neovim プロセスから同時に DB へアクセスがあると困ります。 ▸ シンプルに、ファイルによるロック(O_CREAT|O_EXCL)を利用しています。 -- ロックの取得 while true do -- "wx" は O_CREAT|O_EXCL を意味します。 local err, fd = async.uv.fs_open(lock, "wx", 384) -- 0o0600 if not err then async.uv.fs_close(fd) break end async.util.sleep(some_interval) -- 暫く待ってもう一度 end
DB アクセスの排他制御(その 2) ▸ オープンに成功したらロックが取れた証拠です。 ▸ 処理が終わったらそのロックファイルを削除します。 -- 何らかの重い処理 -- ロックの解放 async.uv.fs_unlink(lock) -- 他のインスタンスでロック取得のための -- fs_open が成功するようになります。
DB の更新の検知 ▸ DB は一度メモリに読み込むと、その後自動的には内容が更新されません。 ▸ 別の Neovim プロセスなどで更新されたら検知して再読み込みします。 local handler = vim.uv.new_fs_event() handler:start(file, {}, function(err, filename, events) { async.void(reload_when_it_is_already_stale)(filename) }) ▸ File change events - Filesystem - libuv documentation ▸ :h uv_fs_event_t
fd, rg を使ったファイルの列挙 ▸ このプラグインには DB に無いファイルを追加で列挙する機能があります。 ▸ fd や rg を使ってカレントディレクトリのファイルを再帰的に取得します。 ▹ どちらも無い場合は Lua のロジック(vim.fs.dir)にフォールバックします。 :Telescope frecency workspace=CWD したところ
fd, rg を使ったファイルの列挙(その 2) ▸ Producer - Consumer パターンを使い、telescope.nvim のコルーチンと 非同期に通信しながら処理します。 local tx, rx = async.control.channel.mpsc() async.void(search_with_external_cmd)(tx) while true do local entry = rx.recv() 内部で tx.send(entry) する。 結果が返るまでここでブロック if not entry or process_result(entry) then break end end process_complete() これらは telescope.nvim の関数です。
ユーザーの操作を妨げない仕組み ▸ Lua のコルーチンは「協調的」です。そのため、明示的にスケジューリング を行わない限り、他のコルーチンに制御を移しません。 ▹ ▸ この辺は以前の記事に書きました。 そのため、実行に長く掛かるコルーチンでは、ユーザーの操作を 妨げないように、CPU を解放する処理を時々挿入する必要があります。 ▹ これが無いと、ファイルの列挙が完了するまでキーを押しても反応しない、 などの問題が起こり得ます。 ▹ telescope.nvim 本体ならこの辺とかに入っています。
操作を妨げない仕組み(その 2) local count = 0 while true do ▸ この例では、20 件列挙する毎に制御を 別のコルーチンに移しています。 local entry = rx.recv() count = count + 1 if count % 20 == 0 then async.util.scheduler() end if not entry or process_result(entry) then break end end process_complete() ▸ どういう所に挿入すべきかは、実際に 色々試して考えるしかありません。
Semantic Versioning の導入 ▸ 新しく追加した機能を告知できて便利。 ▸ デフォルト値を変えるなど破壊的変更を行う際にも安心。 ▸ lazy.nvim が普及したお蔭で、ユーザーには安定したバージョンを 導入させつつ開発中の機能を入れ易くなりました。 { "nvim-telescope/telescope-frecency.nvim", -- 常に最新の安定版を使います version = "*", }
今後の展望 ▸ ▸ Frecency v2 のロジックをデフォルトにする。 ▹ 実装はできてますがまだ v1 がデフォルト。 ▹ ついでにデフォルト値を変えて 2.0.0 にバージョンを上げたい(破壊的変更)。 fzf syntax を使ってマッチしたい。 ▹ ▸ telescope-fzf-native.nvim と同じもの。 他の frecency 実装を調べて改善したい。 ▹ 特に snacks.nvim のやつはパフォーマンスが良さそう。
まとめ
まとめ ▸ ▸ この発表では以下の 3 点に分けて最近の Neovim Lua について話しました。 ▹ plenary.nvim の使い方 ▹ 0.10 時代の Neovim Lua ▹ 実践編: telescope-frecency.nvim で見る Neovim Lua 今回の発表を元に、みなさんも一歩進んだプラグインを書いてみましょう。
おまけ: 便利なドキュメント集 ▸ Documentation - Neovim ▹ ▸ help - Vim日本語ドキュメント ▹ ▸ luv(libuv の Lua バインディング)のドキュメント。:h luvref.txt でも読める。 libuv documentation ▹ ▸ Vim の基本的なことは日本語で読んだ方が早い。 luv/docs.md at master · luvit/luv ▹ ▸ Neovim 本家。内容は常に最新の HEAD に追随しているので注意。 libuv 自体について知りたくなった時はこちら。 LuaJIT ▹ Lua 5.1 との差分を知りたい時はこちら。
おしまい