init.vimをinit.luaに移行する vim.optとvim.keymap.setの書き換え方
Neovimの設定をinit.vimからinit.luaに書き換えると、行数こそ大きく変わりませんが、「書いたのに効かない」が何度も起きます。原因の多くは文法ではなく、Luaに移ったことで変わった約束事のほうにあります。setがvim.optになると値が表で返ってくる、let g:の名前に#があると書けない、lua/に分けた設定は:source $MYVIMRCで読み直されない、といったものです。
この記事は、vimrcの各行をLuaにどう訳すかを、訳したあとに効かなくなる理由と一緒に並べます。一度に全部書き換えず、既存のvimrcを読み込んだまま1行ずつ移す手順から始めます。
検証環境:NVIM v0.12.6とVim 9.1.1752(macOS)。HOMEを一時ディレクトリに向け、その下に.vimrcと.config/nvim/init.lua・lua/opts.luaを作って起動し、:verbose setやmaparg()の結果を--headlessで書き出しました。掲載している出力はすべてその実行結果です。:restartだけは同梱ヘルプで確かめたもので、実際には打っていません。
目次(9項目)
最初はvimrcをそのまま読み込んでから移す
VimとNeovimを両方使っているなら、~/.vimrcを捨てずにinit.luaから読み込むのがいちばん安全な出発点です。init.luaの1行目に次を書きます。
-- ~/.config/nvim/init.lua
vim.cmd.source(vim.fn.expand("~/.vimrc"))
require("opts")
vim.cmd.sourceはExコマンドの:sourceをLuaから呼ぶ書き方で、~はそのままでは展開されないのでvim.fn.expand()を通します。~/.vimrcにset numberとlet g:foo#bar = 1を書いて起動すると、Neovim側で次のように見えました。
number
Last set from ~/.vimrc line 1
同じ~/.vimrcをVim 9.1で読ませてもLast set from ~/.vimrc line 1になり、g:foo#barも1です。こうしておけば、Lua側へ移した行をvimrcから消していくだけで移行が進み、途中で止めてもどちらのエディタも壊れません。Neovimでしか使わない行(LSPやtreesitterの設定)から先にLuaへ移し、VimとNeovimで共通のsetは最後まで残しておく、という順番が手戻りの少ないやり方です。
ひとつ注意があるのは、init.vimとinit.luaを同じディレクトリに両方置くとNeovimが起動時にエラーを出す点です。移行中にvimrcの中身を残したいなら、init.vimを残すのではなく、上のように別名のファイルをsourceする形にします。両方を置いたときのエラーと設定ファイルの役割分けはinit.vimとginit.vimの違い(Neovimの設定ファイル)で扱っています。
setの書き換え(vim.opt・vim.o・vim.opt_localの違い)
setの訳し先は3系統あります。vim.oはVim scriptの&と同じで、値を文字列や数値のまま読み書きします。vim.optはリストや辞書の形をしたオプションを表として扱える書き方です。見た目は似ていても、読んだときに返ってくる型が違います。
print(type(vim.opt.listchars), type(vim.o.listchars), vim.o.listchars)
-- table string tab:> ,trail:-,nbsp:+
vim.opt.listcharsは値そのものではなく、オプションを操作するための表を返します。if vim.opt.listchars == "..."のように文字列と比べても一致しないので、中身を読みたいときはvim.oを使うか、vim.opt.listchars:get()で表に変換します。逆にset listchars+=eol:$のような追記はvim.optのほうが素直に書けます。
vim.opt.listchars:append({ eol = "$" })
print(vim.o.listchars)
-- eol:$,nbsp:+,tab:> ,trail:-
+=がvim.opt.listchars:append()、^=がvim.opt.listchars:prepend()、-=がvim.opt.listchars:remove()に対応します。出力を見ると要素の並びが書いた順ではなくなっていますが、listcharsは順番に意味を持たないので表示は変わりません。並びに意味があるruntimepathのようなオプションでは、appendとprependを取り違えないようにします。
バッファやウィンドウごとの値を持つオプションは、setlocalとsetの違いがそのまま残ります。新しいバッファで試すと次のようになりました。
vim.opt_local.shiftwidth = 8 -- local sw 8 / global sw 2
vim.bo.shiftwidth = 4 -- local 4 / global 2
vim.o.shiftwidth = 6 -- local 6 / global 6
vim.opt_localとvim.boはどちらもsetlocalで、グローバル値には触りません。vim.oはsetと同じく両方を書き換えます。vim.boはバッファ、vim.woはウィンドウのオプション専用で、取り違えてvim.boからウィンドウのオプションであるnumberを設定すると、cannot be passed for window-local option 'number'というエラーで止まります。vim.opt_localはこの区別をせずに書けるので、迷ったらこちらを使うと安全です。
let g:と変数の書き換え(vim.gと#を含む名前)
let g:mapleader = " "はvim.g.mapleader = " "、let b:はvim.b、環境変数はvim.envに移ります。ここまでは機械的に置き換えられますが、プラグインの設定でよく見る#入りの名前だけは同じ形で書けません。
vim.g.foo#bar = 1
-- [string "vim.g.foo#bar = 1"]:1: '=' expected near '#'
Luaでは#が長さ演算子なので、ドットの後ろに置くと構文エラーになります。この場合は添字の形にします。
vim.g["foo#bar"] = 1
エラーはinit.luaを読み込んだ時点で出て、そのファイルの残りの行は実行されません。1行の書き損じで後ろの設定がまとめて効かなくなるので、移したら起動して:messagesを確かめる習慣をつけておくと原因を探しやすくなります。
mapの書き換え(vim.keymap.setとnoremapの既定)
nnoremapやinoremapはvim.keymap.setにまとまり、モードは第1引数で指定します。{"n", "x"}のように表で渡せば複数のモードに一度に張れます。
vim.keymap.set("n", "<Leader>w", "<Cmd>write<CR>")
vim.keymap.set("i", "jk", "<Esc>")
vim.keymap.set({ "n", "x" }, "<Leader>y", '"+y')
移行で挿入モードのマッピングが効かなくなるときは、まずこの第1引数を疑います。inoremap jk <Esc>を訳すつもりで"n"と書くと、マッピング自体は登録されるのでエラーは出ません。上の3行のあとでmaparg("jk", "n")は空、maparg("jk", "i")は<Esc>でした。
もうひとつの違いは、vim.keymap.setが既定で再マップしないことです。nnoremap相当で、nmap相当にするにはremap = trueを付けます。自分で作ったマッピングを右辺に書いた場合で試しました。
vim.g.mapleader = " "
vim.keymap.set("n", "<Leader>d", "yyp")
vim.keymap.set("n", "Q", "<Leader>d") -- 何も起きない
vim.keymap.set("n", "U", "<Leader>d", { remap = true }) -- 行が複製される
Qは<Leader>dを素のキーとして送るので、スペースでカーソルが動き、dが次の入力を待つだけで終わりました。vimrcでnmapと書いていた行をremapなしで訳すと、こういう形で黙って動かなくなります。
一方、右辺が<Plug>で始まる場合はremap = trueが無くても動きました。vim.keymap.set("n", "<Space>a", "<Plug>(my-dup)")はmaparg()でnoremapが1と出るのに、押すと行が複製されます。同梱ヘルプの:help lua-guide-mappingsに|<Plug>| mappings are always expanded even with the default `remap = false`とあり、:help :nnoremapの説明にもWhen <Plug> appears in the {rhs} this part is always applied even if remapping is disallowed.と書かれています。Vim 9.1のヘルプにも同じ文があるので、これはLua固有の扱いではありません。つまりプラグインの<Plug>を呼ぶ行はremapを気にせず訳してよく、気をつけるのは自分の別のマッピングを右辺に書いた行です。<Leader>の決め方とmapleaderを先に設定する理由はVimの<Leader>キーとmapleaderの設定にまとめています。
<expr>付きのマッピングはexpr = trueを付け、右辺にLuaの関数を渡せます。関数の戻り値がキー列として実行されます。
vim.keymap.set("n", "j", function()
return vim.v.count == 0 and "gj" or "j"
end, { expr = true })
lua/に分けてrequireする
init.luaが長くなったら、~/.config/nvim/lua/の下にファイルを分けてrequireで読みます。lua/opts.luaならrequire("opts")、lua/user/keymaps.luaならrequire("user.keymaps")です。拡張子とlua/は書きません。
分けたあとで:verbose setを使うときは、出てくるファイル名に注意が要ります。lua/opts.luaの2行目でvim.opt.number = trueを設定し、普通に起動して確かめると次のように出ました。
number
Last set from ~/.config/nvim/init.lua (run Nvim with -V1 for more details)
設定したのはlua/opts.luaなのに、requireを呼んだinit.luaの名前が出ています。行番号もありません。nvim -V1で起動し直すと、本当の場所が出ます。
number
Last set from ~/.config/nvim/lua/opts.lua line 2
Vimscriptのvimrcに慣れているとLast set fromを一発の答えとして読みがちですが、Luaで分けた設定では-V1を付けないと手がかりが1段浅くなります。autocmdも同じようにlua/へ移せますが、読み直しで二重に登録される問題があるので、autocmdとaugroupの使い方で書いたnvim_create_augroupのclearと組み合わせて使います。
:source $MYVIMRCで反映されない理由と直し方
移行していちばん戸惑うのがここです。lua/opts.luaにvim.g.x = 1と書いて起動し、ファイルをvim.g.x = 2に書き換えてから:source $MYVIMRCを実行しました。
:echo g:x " 1
" opts.lua を vim.g.x = 2 に書き換える
:source $MYVIMRC
:echo g:x " 1
:lua package.loaded["opts"] = nil
:source $MYVIMRC
:echo g:x " 2
$MYVIMRCはinit.luaを指しているので、init.lua自体は読み直されています。それでも値が変わらないのは、requireが一度読み込んだモジュールをpackage.loadedという表に覚えておき、2回目以降はファイルを開かずにその結果を返すからです。Vimscriptの:sourceが毎回ファイルを頭から実行するのとは仕組みが違います。package.loaded["opts"]をnilにすると覚えていた分が消え、次のrequireでファイルが読み直されました。
ファイルを何本にも分けているなら、毎回nilを書くのは手間です。Neovim 0.12には:restartがあり、ヘルプによれば現在のセッションを保存してNeovimを起動し直し、セッションを戻します。設定を丸ごと読み直したいならこちらのほうが確実です。:sourceで読み直す方式は、autocmdの二重登録や、すでに張ったマッピングが消えずに残ることも含めて、Vimscript時代から完全ではありませんでした。vimrcの読み直しそのものについてはVimでファイルとvimrcを再読み込みする方法で扱っています。
対訳の早見表
| Vim script | Lua | 注意 |
|---|---|---|
set number | vim.opt.number = true | vim.oでも同じ |
set listchars+=eol:$ | vim.opt.listchars:append({ eol = "$" }) | vim.optは表を返す |
setlocal sw=4 | vim.opt_local.shiftwidth = 4 | vim.boでも可 |
let g:foo#bar = 1 | vim.g["foo#bar"] = 1 | ドット記法は構文エラー |
nnoremap | vim.keymap.set("n", …) | 既定で再マップしない |
nmap | vim.keymap.set("n", …, { remap = true }) | <Plug>なら不要 |
inoremap | vim.keymap.set("i", …) | モードの取り違えはエラーにならない |
<expr> | { expr = true } | 関数を渡せる |
source ~/.vimrc | vim.cmd.source(vim.fn.expand("~/.vimrc")) | ~は展開してから渡す |
この記事のために<Plug>のマッピングをremapなしで張り、動かないことを確かめるつもりで行数を数えたら、行がきちんと増えていました。バッファを読み直して2回流しても同じだったので、ヘルプを引いてようやく自分の思い込みのほうだと分かりました。それまでremapが要るのは<Plug>のためだと覚えていて、自分で作った<Leader>のマッピングを別のキーから呼ぶ行のことは考えたこともありませんでした。
まとめ
移行はvimrcをvim.cmd.sourceで読み込んだ状態から始め、Luaに移した行をvimrcから消していくと、VimとNeovimの両方を壊さずに進められます。setはvim.optかvim.oに訳しますが、読み出すときはvim.oが文字列、vim.optが表を返すことを覚えておきます。#を含む変数名は添字で書き、nmapだった行にはremap = trueを付けます。
lua/に分けたあとで:source $MYVIMRCが効かないのはrequireのキャッシュが原因で、package.loadedから消すか:restartで起動し直せば反映されます。:verbose setで場所を探すときはnvim -V1で起動しないとinit.luaの名前しか出ません。