16
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

0.10 時代の Neovim Lua

Last updated at Posted at 2024-10-10

この記事は Vim 駅伝 の 2024/10/11 の記事です。前回は mikoto2000 さんによる「Visual Studio で Vim をビルドする - mikoto2000 の日記」でした。

0.10 になって便利な機能が増えたからみんなも使おうよ、という趣旨の記事です。

Neovim 0.10 になって増えた機能

News-0.10 - Neovim docs というページに 0.10 になっての変更点がまとまっているのですが、これだけでは多過ぎて把握できないと思います。「dotfiles やプラグインを書く時に便利な機能」という括りで抜き出してみても以下のようなものが挙げられます(多過ぎるので畳んであります)。

0.10 で追加された機能(一部)
  • nvim_create_autocmd() のコールバック関数が true を返すと、自動コマンド自身が削除されるようになりました。
  • vim.islist() がちゃんと「配列」の時だけ true になるようになりました。
    • 今までは歯抜けのテーブル({ [1] = "hoge", [3] = "fuga" } みたいなの)でも true になってました。
  • Floating Window 関連。
    • Floating Window を一時的に隠すことができるようになりました(nvim_win_set_config()hide オプション)。
    • Floating Window と通常のウィンドウを相互に変換できるようになりました。
  • Extmark(nvim_buf_set_extmark())関連。
    • Inline virtual text が使えるようになりました。
    • url オプションをサポートしましました。OSC 8 コントロールシーケンスを使い、対応したターミナルではマウスクリックでリンクが開きます。
  • Vimscript の exists() が Lua の関数に対しても働くようになりました。
    • 例えば、:echo exists('v:lua.require("lazy").stats') とすると 1 と表示されます。
  • vim.json.encode() / vim.json.decode() が追加されました。JSON の読み書きができます。
  • vim.text.hexencode() / vim.text.hexdecode() が追加されました。バイト表記の文字列と相互に変換できます。
  • vim.ui.open() が追加されました。OS のデフォルトの関連付けでファイルや URL を開くことができます。

その他細々とした変更点がもっと沢山あるのですが、この記事では特に以下の 2 つについて詳しく紹介したいです。

  • vim.iter() が追加されました。
  • vim.system() が追加されました。

vim.iter() の使い所

Lua は他の言語で言う「配列」や「ハッシュ(連想配列)」をまとめて「テーブル」という文法で表します。vim.iter() はこのテーブル、及び、イテレータに対して様々な処理を加えるための仕組みです。

イテレータとは

任意の集合について要素を列挙するために利用される仕組みです。多くの場合、列挙したいテーブルに紐付いたクロージャとして実装されます。

iterator - Lua - Neovim docs
7.1 Iterators and Closures - Programming in Lua

みなさんも良く使う for A in B do C end ループ、ここに現れる B がイテレータです。良く使われる ipairs()pairs() は、「テーブルを引数に与えるとイテレータを返す関数」なのです。

「配列」と「ハッシュ」

Lua にはあくまで「テーブル」という概念しか無いので「配列のようなテーブル」「ハッシュのようなテーブル」と呼ぶのが正しいのですが、以下では簡単のため「配列」「ハッシュ」と表記しています。

Iter:filter(), Iter:map(), Iter:fold()

一番良く使うのは filter()map()、そして fold() でしょうか。

local result = vim
  .iter({ 1, 2, 3, 4, 5 })
  :filter(function(v)
    return v % 2 ~= 0
  end)
  :map(function(v)
    return v * 2
  end)
  :fold(0, function(a, b)
    return a + b
  end)
print(result) --> 18

filter() により奇数を抽出し、map() によりそれらを全部二倍にし、fold() で足し合わせています。JavaScript など他の言語でも良く見る構文ですね。

イテレータと Iter:enumerate()

enumerate() はイテレータが毎回返す要素の前に、配列で言う添字を追加して返すようにする関数です。

vim.iter(vim.gsplit("This is a pen.", " ")):enumerate():each(function(i, word)
  print(("%d %s"):format(i, word))
end)
--> 1 This
--> 2 is
--> 3 a
--> 4 pen.

vim.gsplit() は文字列を指定した文字で分割して列挙するイテレータを返す関数です。vim.iter() の引数にはイテレータを与えることもできます。ipairs() は配列の要素と併せ添字を返してくれるイテレータを作ってくれますが、enumerate() はイテレータに対して同じことをやってくれる、と言えます。

vim.iter(vim.api.nvim_win_get_config()):enumerate():each(function(i, key, value)
  print(("%d %s: %s"):format(i, key, value))
end)
--> 1 height: 78
--> 2 split: left
--> 3 width 120
--> ……

nvim_win_get_config() は指定したウィンドウの設定をハッシュで返します。この場合も同様に、「添字、キー、値」の順で要素が渡されます。

説明を省いてしまいましたが、each() は列挙された要素に関して繰り返し関数を実行します。但し、for ループのように break で脱出することはできません(事前に filter() しておきましょう)。

Iter:totable()

fold() を使えば、究極的には、最後にどんな結果を返すのも思いのままです。もし結果を配列で欲しいなら totable() を使うのがコードとしても読み易いでしょう。

local foo = vim
  .iter({ 1, 2, 3, 4, 5 })
  :filter(function(v)
    return v % 2 ~= 0
  end)
  :map(function(v)
    return v * 2
  end)
  :totable()
vim.print(foo) --> { 2, 6, 10 }

要素に nil がある時の注意

JavaScript などの他の言語と違うのは、totable() を呼ぶ時に値が nil の要素は除かれてしまう、という所です。

local foo = vim
  .iter({ 1, 2, 3, 4, 5 })
  :map(function(v)
    return v > 3 and v
  end)
  :totable()
vim.print(foo) --> { 4, 5 }

つまり、最初の例の filter(), map() は、下記の様に map() 一つに置き換えることができます。

local foo = vim
  .iter({ 1, 2, 3, 4, 5 })
  :map(function(v)
    return v % 2 ~= 0 and v * 2 or nil
  end)
  :totable()
vim.print(foo)

他の言語に慣れた身からするとちょっと罠かも知れませんね。

複数の返り値を返すイテレータの場合

Lua のイテレータは多値を返すことができます。

local foo = vim
  .iter(vim.fs.dir ".")
  :filter(function(name, _)
    return name:lower():match "^d"
  end)
  :totable()
vim.print(foo)
--> {
-->   { "Desktop", "directory" },
-->   { "Documents", "directory" },
-->   { "Downloads", "link" },
-->   { "diary.txt", "file" },
--> }

vim.fs.dir() は指定したディレクトリのエントリを列挙する関数です。この関数を使うと name, type という二つの値が一度に列挙されます。totable() は多値を返すイテレータに関しては入れ子のテーブルにまとめて返してくれます。

totable() は配列を返すことしかできません。ハッシュやもっと複雑な結果を得たい場合は fold() を使いましょう。

実践的な例

外部コマンドの出力を清書する

local job = vim.system({ "curl", "wttr.in/Tokyo?format=j1" }):wait()
local w = vim.json.decode(job.stdout)
print(vim
  .iter(w.weather)
  :map(function(day)
    return vim
      .iter({ "date", "mintempC", "maxtempC" })
      :map(function(key)
        return day[key]
      end)
      :join "  "
  end)
  :join "\n")
# 実行例
$ nvim --clean -l /tmp/hoge.lua
2024-10-01  23  26
2024-10-02  26  26
2024-10-03  22  25

後述の vim.system() と組み合わせ、東京の最低気温と最高気温を出してみました。コードを見てそのまま読むだけで直感的に何やってるか分かりますね!

カレントディレクトリ配下についてファイル名の出現回数を調べる

local seen_map = vim
  .iter(vim.fs.dir(".", { depth = 100 }))
  :take(100000)
  :filter(function(_, type)
    return type == "file"
  end)
  :map(function(name, _)
    return vim.fs.basename(name)
  end)
  :fold({}, function(a, basename)
    a[basename] = (a[basename] or 0) + 1
    return a
  end)
-- ↑ キーにファイル名、値に出現回数の入ったハッシュが返ります。

local seen = vim
  .iter(seen_map)
  :filter(function(_, count)
    return count >= 100
  end)
  :totable()
-- ↑ 出現回数が 100 を超えたものについて、{ ファイル名, 出現回数 } の配列を作ります。

table.sort(seen, function(a, b)
  return a[2] > b[2] or (a[2] == b[2] and a[1] < b[1])
end)
-- ↑ 要素の一つ目(a[1])はファイル名、二つ目(a[2])は出現回数です。

vim.iter(ipairs(seen)):each(function(i, entry)
  print(("%4d %4d %s"):format(i, entry[2], entry[1]))
end)
-- ↑ 結果を表示します。
$ nvim --clean -l /tmp/hoge.lua
   1  665 .com.apple.containermanagerd.metadata.plist
   2  436 MYMETA.yml
   3  435 MYMETA.json
   4  432 Changes
   5  407 MANIFEST

   ……

だいぶ複雑になりました。この例ではカレントディレクトリ配下のエントリを再帰的に列挙し、ファイル名の出現回数を表示しています。ホームディレクトリなど巨大なディレクトリで実行すると大変時間が掛かりますから、ここでは要素の数を最大 100,000 に限定しています(take() を使っています)。

上記の例は僕の環境での実行結果です。Perl のレポジトリが多数あった為にその関連のファイルが多くなっています。

vim.iter を使う際の注意点

vim.iter には一つ注意点があります。ヘルプにも記載があるのですが、要素の数が大変多い配列に対して呼び出すと性能に影響が出る可能性があります。

Note: vim.iter() scans table input to decide if it is a list or a dict; to avoid this cost you can wrap the table with an iterator e.g. vim.iter(ipairs({…})), but that precludes the use of list-iterator operations such as Iter:rev().

(訳)vim.iter() は対象が配列かハッシュか判別するために内容を走査します。このコストが無視できない場合は予めテーブルをイテレータにしてください(例えば vim.iter(ipairs({…})))。しかしその場合、配列に対する操作、例えば Iter:rev() などは利用できなくなります。

-- 非常に大きなテーブル
local hoge = { "aa", "ab", "ac", ……, "az", "ba", "bb", "bc", …… }
-- これだと遅い
vim.iter(hoge):each(function(item) …… end)
-- これなら OK
vim.iter(ipairs(hoge)):each(function(i, item) …… end)

とはいえ、一般的な用途ならこれが問題になることはまず無いでしょう。

vim.system() の使い所

Vim/Neovim では昔から、「外部プロセスを起動してそれとやりとりする」のが大変でした。様々なソリューションが誕生し、プラグインでがんばって実現するものもありました(vimproc など)。現在の Neovim ではビルトインで使える機能として以下のようなものがあります。

system() / systemlist()

system() / systemlist() は Vimscript の関数です。Lua からは vim.fn.system() のように呼び出せます。この関数は以下の理由から完全に過去の遺物ですので、新しく使うことは避けましょう。

  • 同期的に実行されるためプロセス終了までスクリプトの処理が停止します。
  • プロセスの終了コードが取得できません。
  • プロセスを起動する際に必ずシェルを経由するため、引数を適切にエスケープする必要があります。
  • 標準出力と標準エラー出力を分けて扱えません。。
  • 関連するオプション、関数が多く、それらを全て把握しないと適切に利用できません。

一応、使用例は挙げておきます。

let str = system('ls')          " ls コマンドの結果が変数にそのまま入ります。
let lines = systemlist('ls')    " 結果が改行文字で分割され、配列として格納されます。

jobstart()

jobstart() は Vimscript の関数です。system() と同様、Lua からは vim.fn.jobstart() のように呼び出せます。system() と比べると、以下のような利点があります。

  • プロセスは非同期に実行されるためスクリプトの処理が止まりません。
  • シェルを介さずに外部プロセスを開始できるため、引数のエスケープに煩わされることがありません。
  • プロセスの終了コードを取得できます。
  • 標準出力と標準エラー出力を分けて扱えます。
  • 新しく PTY(疑似端末)を作ってプロセスを開始し、相互に通信できます。

最後の PTY に関する項目だけは後述の vim.system() では実現できませんが、それ以外のユースケースでは vim.system() を優先的に利用するようヘルプでは注意されています。

Note: Prefer vim.system() in Lua (unless using the pty option).

以下に簡単な利用例を示します。

" ls -l の結果をバッファーに出力します。
call jobstart(
    \ { 'ls', '-l' },
    \ { 'on_stdout': { job, data, name -> append(line('.'), data) }
    \ )

Job は 関連する Channel や Terminal と共に、Neovim が Vim から分岐して開発が始まった際、一番最初に実装されたものの一つです。長らく Neovim の顔として活躍してきましたが、vim.system() の登場と共に置き換わっていくことになるでしょう。

E5560 エラー

これは jobstart() に限った話では無いのですが、Lua から Vimscript の関数を呼ぶ際に、E5560 エラーに遭った読者も多いでしょう。

local timer = vim.uv.new_timer()
timer:start(20, 0, function()
  timer:close()
  vim.fn.jobstart({ "ls" }, {
    on_exit = function()
      print "done!"
    end,
  })
end)

このスクリプトを保存して実行すると以下のようなエラーが表示されます。

Error executing luv callback:
/tmp/hoge.lua:4: E5560: Vimscript function must not be called in a lua loop callback
stack traceback:
        [C]: in function 'jobstart'
        /tmp/hoge.lua:4: in function </tmp/hoge.lua:2>

これは vim.fn.* 関数だけでなく、多数の vim.api.* 関数についても当て嵌ります。一部の関数では、引数に指定するコールバックの中でこれらの関数を呼べないのです。

-- これならエラーは起こりません。
local timer = vim.uv.new_timer()
timer:start(20, 0, function()
  timer:close()
  vim.system({ "ls" }, {}, function()
    print("done!")
  end)
end)

このような理由からも vim.system() を使う方が無難です。すでに書きました「PTY に接続したい場合」以外は vim.system() を使いましょう。

vim.uv.spawn()

Neovim の Lua 環境は libuv のイベントループ上で実行されています。Lua からは libuv に、その Lua binding である luv によってアクセスできます。vim.uv.spawn() は libuv の uv_spawn() を呼ぶ関数です。

-- ls -l の実行結果をメッセージに出力します。
local stdout = vim.uv.new_pipe()
local handle, pid = vim.uv.spawn("ls", {
  args = { "-l" },
  stdio = { nil, stdout, nil },
}, function(code, signal)
  if code ~= 0 then
    print(("failed with exit code: %d, signal: %d"):format(code, signal))
  end
end)
local count = 0
vim.uv.read_start(stdout, function(err, data)
  assert(not err, err)
  if data then
    count = count + 1
    print(("chunk %2d: %s"):format(count, data))
  else
    print("stdout end")
  end
end)

例を見れば分かります通り、非常に低レベルで扱いにくい関数です(生の C を書いている気分になります)。多くのプラグイン作者が、この関数をラップした「Process クラス」のようなものを今まで書いてきました。それを Neovim 本体に組み込んで誰でも使えるようにしたのが、次に紹介します vim.system() です。

Neovim 0.9 までは libuv の関数にアクセスするには vim.loop というプロパティを使って来ましたが、0.10 では vim.uv を使うようになっています。vim.loop も依然として使えますが、次期バージョンで deprecated になる予定です。

vim.system()

やっと出て来ました。これが Neovim 0.10 時代の本命です。

-- 非同期に実行するバージョン
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)

簡単ですね!内部的には前述の vim.uv.spawn() を呼んでいるのですが、記述が直感的で分かり易いです。

-- 同期的に実行するバージョン
local job = vim.system({ "ls", "-l" }, { text = true }):wait()
if job.code == 0 then
  print(job.stdout)
else
  print(("code: %d, stderr: %s"):format(job.code, job.stderr))
end

jobstart() と違い、同期的に実行することもできます。この場合はプロセスの終了までスクリプトの実行が止まってしまいますが、簡単な使い捨てのコードなら十分でしょう。

コマンドが実行可能かどうか判断する

何らかの理由でプロセスが起動できなかった時、vim.system() は例外を起こして終了します。これを利用してコマンドが実行可能か判定できます。

local ok, err_or_job = pcall(vim.system, { "git", "version" })
if ok then
  -- git が有効なコマンドの時の処理
  local job = err_or_job
  -- ……
else
  -- git が無効なコマンドの時の処理
  local err = err_or_job
  print("executable `git` not found: " .. err)
end

Lua / libuv には Vimscript の executable() に当たる関数が無いので重宝します。もちろん、指定するコマンドは重要な副作用を起こさないものを選びましょう。

堅実な書き方

プラグインを書く場合など、エラー処理が必要な場面では vim.system() の返り値を利用して以下のように書くと良いでしょう。

-- プロセスが起動できなかった場合に備え、pcall() を利用して安全に呼び出します。
local ok, err_or_job = pcall(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)

-- プロセスの起動に失敗した時の処理
if not ok then
  local err = err_or_job
  print(("failed to spawn: %s"):format(err))
else
  -- プロセスの起動に成功した場合でも、1 秒経ってもプロセスが
  -- 終了していなかったら強制終了します。
  local job = err_or_job
  vim.defer_fn(function()
    if not job:is_closing() then
      print(("job: %d has timed-out. exiting……"):format(job.pid))
      job:kill()
    end
  end, 1000)
end

jobstart() の節で書きましたように、極限られた用途を除いては vim.system() で全てのユースケースを賄えます。これからは積極的に利用していきましょう。

おまけ

vim.system() 使ってると、「これ、昔の Node.js みたいにコールバックばかりになってイヤだなあ。async / await 使いたいなあ」とか思いますよね。思いますよね?

そういう時は plenary.nvim の async モジュールを使うのがオススメです。以前解説記事を書きましたのでそちらもどうぞ。

plenary.nvim による非同期処理 #Lua - Qiita

local async = require "plenary.async"
local async_system = async.wrap(vim.system, 3)

async.void(function()
  local job = async_system({ "ls", "-l" }, { text = true })
  vim.iter(vim.gsplit(job.stdout, "\n")):each(function(line)
    print("ls -l output: " .. line)
  end)
end)()

こんな感じで、非同期プログラミングがすっきり書けるようになります。

終わりに

Neovim 0.10 がリリースされたことで Lua でスクリプト書くのがますます楽しくなりました。みなさんも積極的に新機能を使っていきましょう。

16
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?