🏃

Neovim のキーマップ設定をより便利にする DSL

に公開

はじめに

Neovim のキーマップ設定は vim.keymap.set() 関数を使うのが鉄板です。たとえば挿入モードで左右のカーソル移動を Emacs ライクに行いたければ

vim.keymap.set("i", "<C-b>", "<C-g>U<Left>")
vim.keymap.set("i", "<C-f>", "<C-g>U<Right>")

とシンプルに書けます。vim.keymap.set() は便利であるものの、長く使っているうちに以下のような細かい不満が気になり始めました。

  • 第1引数(モード)を書き忘れがち
  • bufferdesc といったオプションを追加する心理的なコストが高く、忘れ or サボりがち
  • 文字列リテラルが3つ並ぶなどの理由で読みづらいことがある

そこでこれらの欠点を改善・解決するインターフェースを自作し、自分の設定ファイル内で用いることにしました。

vim.keymap.set() 関数を振り返る

vim.keymap.set() は以下のように 3〜4 つの引数を取る関数です。

-- «options» は省略可
vim.keymap.set(«modes», «lhs», «rhs», «options»)

それぞれの引数は以下の意味を持ちます。

引数 意味
«modes» string | string[] 適用対象のモード("n", {"n", "x", "o"} など)
«lhs» string ユーザが入力するキー列(左辺)
«rhs» string | function 実行される内容(右辺)
«options» table | nil キーマップに関するオプション(buffer, desc, expr など)

いくつか例を挙げてみます。

例1: シンプルなキーマップ

vim.keymap.set("i", "<C-b>", "<C-g>U<Left>")

先ほど挙げた、最もシンプルなキーマップ定義の例です。この関数呼び出しを評価すると

  • 挿入モード ("i") で

  • CTRL キー + B ("<C-b>") を押すと

  • <C-g>U<Left> というコマンド列を実行する [1]

というマッピングが設定されます。

例2: 複数モードへの設定

vim.keymap.set({"n", "x", "o"}, "m)", "])")

第1引数にテーブルを与えると複数のモードに対して一度にマッピングが定義されます。この例ではモーション(カーソル移動)に相当するキーマップを定義するため、ノーマルモード ("n")、ビジュアルモード ("x")、オペレータ待機モード ("o") の3つのモードでまとめてキーマップを定義しています。

ちなみに ]) はカーソルを含む括弧の末尾に移動する便利なモーションです。以下記事でも紹介しているので、知らなかった方はぜひ読んでみてください。

https://zenn.dev/vim_jp/articles/2024-06-05-vim-middle-class-features

例3: Lua function の使用

右辺に Lua の関数を指定すると、キー発火時に指定した関数が実行されます。

vim.keymap.set(
    "o",
    "U",
    function()
        for _ = 1, vim.v.count1, 1 do
            vim.fn.search("[A-Z]", "", vim.fn.line("."))
        end
    end,
    {
        desc = [[camelCase の次の大文字直前まで選択]],
    }
)

関数を指定すれば、きわめて自由度の高いキーマップが手軽に定義できます。ただし Lua 関数でキーマップを定義すると :map コマンド等でキーマップ内容を把握するのが困難になるため、第4引数の desc フィールドで自然言語による説明を追加することをおすすめします。

例4: expr マッピング

expr マッピングを使うと、キー発火時の条件によって、実行されるコマンド列を動的に切り替えることができます。 expr マッピングでは右辺に「文字列を返す Lua function」または「Vim script の式として解釈できる文字列」を指定します。

vim.keymap.set(
    "i",
    "A",
    function()
        if vim.fn.mode(0) == "V" then
            return "<C-v>0o$A"
        else
            return "A"
        end
    end,
    {
        expr = true,
        desc = [[行選択モードでも複数行に挿入できる A]],
    }
)

この例は、ビジュアルモードで A が押下されたとき以下を実行するキーマップです。

  • 行選択モードのときは <C-v>0o$A を実行する(矩形選択モードに遷移して A を実行)

  • それ以外のときは A を実行する(デフォルトと同じ挙動)

例5: buffer-local マッピング

buffer オプションを指定すると、特定のバッファでのみ有効なマッピングを指定できます。

vim.keymap.set("n", "q", "<Cmd>quit<CR>", {buffer = true})

この例は q を押すだけで現在のウィンドウを閉じてしまう、少々アグレッシブなマッピングです。しかし、一時的に表示するだけのフローティングウィンドウなどに限定して定義されたものであれば、何ら不自然ではありません。特定のファイルタイプや特殊なウィンドウに対して、こういったマッピングが定義されることはよくあります。

このように buffer-local なマッピングはしばしばアグレッシブであり、その意味で buffer オプションの有無は非常に重要です。ターミナルバッファ用に設定した「q を押下したらバッファを閉じる」というキーマップが、コーディング中に暴発したらどうなるでしょう?buffer オプションの付け忘れは快適な編集体験を大きく損ねることがあります。buffer-local であるべき mapping がグローバルに設定されてしまう、というアンチパターンがあることは意識しておくとよいでしょう。

vim.keymap.set() の課題

vim.keymap.set() の機能に一通り触れたところで、vim.keymap.set() に感じる課題を再度掘り下げてみます。

  • 第1引数(モード)を度々書き忘れる

    • 筆者は度々 vim.keymap.set("j", "gj")vim.keymap.set("jj", "<Esc>") と書いてしまうことがありました。
  • bufferdesc などのオプションを追加する心理的なコストが高い

    • buffer = true は特定のキーマップが buffer-local であることを示す重要なオプションですが、vim.keymap.set() API では第4引数の非必須フィールドの1つにすぎず、忘れてもエラーとはなりません。気を抜くと忘れてしまいがちです。

    • desc は右辺が function 型の場合に、可読性を高める効果があります。しかし、ひとたび

      vim.keymap.set(
         "o",
         "U",
         function()
             ...
         end
       )
      

      と書いたところに desc フィールドをつけるのは少しだけ面倒です。関数定義の末尾にカンマを付け、テーブルを新たに生やす必要があります。 この「ちょっとした面倒」のために desc フィールドの追加をサボってしまう、ということが私は幾度となくありました。

  • スクリプト全体が読みにくい

    • vim.keymap.set() の引数では文字列リテラルが3つ並ぶことが多く、どこが lhs(ユーザ入力)でどこが rhs(変換後のコマンド)なのか一瞬迷うことがあります。

    • マッピング定義を読むとき、expr オプションの有無によって右辺の読み方が大きく変わります。そのためexpr オプションの宣言が右辺の定義より前に書かれているほうが読みやすいのですが、 vim.keymap.set() ではそうなっていません。

考案した mapset インターフェース

mapsetvim.keymap.set() のラッパーであり、設定ファイルで以下を行うことで使用できます。

  • Neovim の設定ディレクトリのどこかに実装を置く(実装は後述)

    • Lua モジュールとして自動で読み込まれる場所ならどこでも構いません。私は$XDG_CONFIG_HOME/nvim/lua/monaqa/shorthand.lua に置いています。
  • キーマップ設定ファイルの冒頭にて読み込む

    -- require の中身は実装を置いた場所に応じて変えてください
    local mapset = require("monaqa.shorthand").mapset
    

これだけで準備は完了。mapset を用いた実際のキーマップ設定例を見てみましょう。

-- 例1: シンプルなキーマップ
mapset.i("<C-b>") { "<C-g>U<Left>" }

-- 例2: 複数モードへの設定
mapset.nxo("m)") { "])" }

-- 例3: Lua function の使用
mapset.o("U") {
    desc = [[camelCase の次の大文字の手前までを選択する]],
    function()
        for _ = 1, vim.v.count1, 1 do
            vim.fn.search("[A-Z]", "", vim.fn.line("."))
        end
    end,
}

-- 例4: expr マッピング
mapset.x("A") {
    desc = [[行選択モードでも複数行に挿入できる A]],
    expr = true,
    function()
        return logic.ifexpr(vim.fn.mode(0) == "V", "<C-v>0o$A", "A")
    end,
}

すでに例からある程度推測できたと思いますが、mapset は以下のようなインターフェースとなっています。

mapset.«mode» («lhs») {«rhs + options»}

変わった記法に見えますが、100% 正当な Lua の構文です。Lua の構文を用いたドメイン固有言語 (DSL) ともいえます。

  • «mode»in など、適用対象のモードを設定するフィールドです。 nxoic などの複合モードも設定可能。
  • «lhs» は左辺を指定するところです。通常は文字列リテラルが入ります。
  • «rhs + option»: 右辺とオプションをまとめて記述するテーブルです。vim.keymap.set() と同一のオプションが指定できます。
    • [1] フィールドの要素(=フィールド名を省略した場合、最初の要素)が自動で rhs として扱われます。

mapset の実装

---@alias map_body string | fun():nil|string

---@class mapset_opts: vim.keymap.set.Opts
---@field [1] map_body
---@alias mapset_inner fun(t: mapset_opts):nil

--- キーマップ定義のショートハンド。
---@param mode string | string[]
---@param buffer_local boolean?
---@return fun(string): mapset_inner
local function mapset_with_mode(mode, buffer_local)
    ---@param lhs string
    ---@return mapset_inner
    return function(lhs)
        ---@param t mapset_opts
        return function(t)
            local body = t[1]
            t[1] = nil
            if t.buffer == nil then
                t.buffer = buffer_local
            end
            vim.keymap.set(mode, lhs, body, t)
        end
    end
end

M.mapset = {
    --- NORMAL mode のキーマップを定義する。
    n = mapset_with_mode("n"),
    --- VISUAL mode のキーマップを定義する。
    x = mapset_with_mode("x"),
    --- OPERATOR-PENDING mode のキーマップを定義する。
    o = mapset_with_mode("o"),
    --- INSERT mode のキーマップを定義する。
    i = mapset_with_mode("i"),
    --- COMMAND-LINE mode のキーマップを定義する。
    c = mapset_with_mode("c"),
    --- SELECT mode のキーマップを定義する。
    s = mapset_with_mode("x"),
    --- TERMINAL mode のキーマップを定義する。
    t = mapset_with_mode("t"),

    --- NORMAL / VISUAL キーマップ(オペレータ/モーションなど)
    nx = mapset_with_mode { "n", "x" },
    --- VISUAL / SELECT キーマップ(制御文字を用いた VISUAL キーマップなど)
    xs = mapset_with_mode { "x", "s" },
    --- VISUAL / OPERATOR-PENDING キーマップ(モーション、text object など)
    xo = mapset_with_mode { "x", "o" },

    --- NORMAL-like mode のキーマップ(モーションなど)
    nxo = mapset_with_mode { "n", "x", "o" },
    --- INSERT-like mode のキーマップ(文字入力など)
    ic = mapset_with_mode { "i", "c" },

    --- iabbrev を定義する。
    ia = mapset_with_mode("ia"),
    --- cabbrev を定義する。
    ca = mapset_with_mode("ca"),

    --- モードを文字列で指定してキーマップを定義する。
    with_mode = mapset_with_mode,
}

M.mapset_local = {
    --- NORMAL mode の buffer-local キーマップを定義する。
    n = mapset_with_mode("n", true),
    --- VISUAL mode の buffer-local キーマップを定義する。
    x = mapset_with_mode("x", true),
    --- OPERATOR-PENDING mode の buffer-local キーマップを定義する。
    o = mapset_with_mode("o", true),
    --- INSERT mode の buffer-local キーマップを定義する。
    i = mapset_with_mode("i", true),
    --- COMMAND-LINE mode の buffer-local キーマップを定義する。
    c = mapset_with_mode("c", true),
    --- SELECT mode の buffer-local キーマップを定義する。
    s = mapset_with_mode("s", true),
    --- TERMINAL mode の buffer-local キーマップを定義する。
    t = mapset_with_mode("t", true),

    --- NORMAL / VISUAL の buffer-local キーマップ。
    nx = mapset_with_mode({ "n", "x" }, true),
    --- VISUAL / SELECT の buffer-local キーマップ。
    xs = mapset_with_mode({ "x", "s" }, true),
    --- VISUAL / OPERATOR-PENDING の buffer-local キーマップ。
    xo = mapset_with_mode({ "x", "o" }, true),

    --- NORMAL-like モードの buffer-local キーマップ。
    nxo = mapset_with_mode({ "n", "x", "o" }, true),
    --- INSERT-like モードの buffer-local キーマップ。
    ic = mapset_with_mode({ "i", "c" }, true),

    --- buffer-local iabbrev を定義する。
    ia = mapset_with_mode("ia", true),
    --- buffer-local cabbrev を定義する。
    ca = mapset_with_mode("ca", true),
}

return M

Lua のメタテーブルを利用すれば mapset テーブルの定義を短く抑えることもできますが、LSP の補完や型チェックを効かせるため、意図的にフィールドを明示的に列挙しました。

mapset のメリット

mapset はあくまで本家たる vim.keymap.set() のラッパーに過ぎず、できることは本家と変わりません。にもかかわらず、本家にくらべて様々なメリットがあります。

モードの書き忘れ・書き損じ防止

mapset.«mode» の形式を採用したことで、モード名を書き忘れることがなくなりました。 存在しないモードを指定すると型エラーになるため、タイプミスにもすぐ気づくこともできます。

また、mapset では 意図的に mapset.v を提供していませんv 指定(:vmap コマンド相当)は「VISUAL モードと SELECT モード[2]の両方にキーマップを指定する」コマンドであり、 VISUAL モードでのみ定義するつもりで指定すると思わぬ副作用をもたらすことがあります。 v の代わりに xs フィールドを指定することで、VISUAL モードと SELECT モード両方が対象だと明示されるようにしています。

柔軟かつ快適なオプション指定

オプションを右辺と同じテーブルに与えることで、オプション指定が柔軟かつ簡単にできるようになりました。オプションが右辺より前に書けるようになり、特に関数を読む前に確認したい事が多い descexpr フィールドを用いるときの可読性が高まりました。

desc フィールドについては、既存のキーマップに新たに追加する心理的ハードルも下がりました。たとえば

mapset.o ("U") {
  function()
      ...
  end
}

という定義に対して desc を追加するなら、1行追加するだけです。

 mapset.o ("U") {
+  desc = [[camelCase の次の大文字直前まで選択]],
   function()
       ...
   end
 }

buffer オプションの付け忘れ防止

mapset にはその工夫も入っています。バッファローカルなマッピングを定義したいときは、冒頭で mapset の代わりに mapset_local を読み込むだけ。

local mapset = require("monaqa.shorthand").mapset_local

-- 例5: buffer-local マッピング
mapset.n ("q") { "<Cmd>quit<CR>" }

すると以降の行で書かれた mapset によるキーマップ定義はすべてバッファローカルになります。バッファローカルなキーマップ設定はたいてい ftplugins/ 以下などに固まっていますから、そういったファイルの冒頭の mapset 変数の定義にさえ気をつければ、以後 buffer オプション忘れを自動的に防ぐことができるのです。

全体的に記述が簡潔に、わかりやすくなる

mapset インターフェースでは mapset.«mode» ("«lhs»") { «rhs + options» } のように mode, lhs, rhs + options という3ブロックが異なる種類の括弧で区切られています。 そのため、単に関数の引数を連ねる記法と比べ、どこが何の役割を果たすのか直感的に理解しやすい構造となっています。

おわりに

上で紹介した mapset は、あくまで私好みのインターフェースを追求した結果であり、万人にとって最適ではないかもしれません。設定ファイルとは自分のためにあるもの。誰の目も気にせず、自分好みの記法を貫ける場所でもあります。ぜひ皆さんも、自分だけのためにこだわり抜いた最高の DSL を考え、形にしてみてください。

脚注
  1. <C-g>U<Left> というコマンド列の意味は割愛します。詳細は :h i_CTRL-G_U:h i_<Left> 参照。 ↩︎

  2. スニペット展開時などで入るモード。 ↩︎

Discussion