Closed9

LuaでNeovimのプラグインを作ってみる際の試行錯誤

monaqamonaqa

一度 Lua で Neovim のプラグインを作ってみようということで。

作りたいもの

ノーマルモードの <C-a><C-x> コマンド(数値のインクリメント・デクリメント)を拡張し、日付や曜日、 Markdown のセクション見出しなど、様々な数値を増減できるようにするプラグイン。

プラグインづくりほぼほぼ初めて + Luaについては完全に初心者なので、そこまで実装が難しくなさそうで、なおかつある程度実用性のありそうな(前からほしいと思っていた)ものを選びました。

類似プラグインとして vim-speeddating があります。

作業場所

https://github.com/monaqa/dial.nvim

monaqamonaqa

考えている仕様

基本的にはVimの仕様と同様に、カーソル下にある数字 or 同じ行でカーソルより右側にある《数字》を、決まった《数字》だけインクリメント・デクリメントする、という動作。
ややこしいので前者の数字を 被加数 (augend)、後者の数字を 加数 (addend) と呼ぶことにする。
たとえばバッファ上に

hogehoge| fugafuga 123 piyo

とあったときに(| はカーソル) <C-a> を押すと

hogehoge fugafuga 12|4 piyo

となる。被加数はバッファ上に置かれた数値であり、加数は上の例では1である(3<C-a> などのようにカウンタで指定可能にする)。

被加数の種類

<C-a> とかでインクリメントできたら便利そうだな―と今考えているもの。上に行くほど優先順位が高い。

  • 十進表記の整数(Vim/Neovimに機能あり)
  • 十進表記の非負整数(- の影響を受けない。個人的に欲しい)
  • 日付
  • 時刻
  • 曜日
  • 16進数表記の非負整数
  • Markdown のヘッダの ### の数
monaqamonaqa

How to write neovim plugins in Luaを参考にして、以下のように書いてみた。
プラグイン名は dial.nvim とした。

plugin/dial.vim

if exists('g:loaded_dial') | finish | endif " prevent loading file twice

let s:save_cpo = &cpo " save user coptions
set cpo&vim " reset them to defaults

" command to run our plugin
nnoremap <expr> <Plug>(dial-increment) '<Cmd>lua require"dial".increment(' .. v:count1 .. ')<CR>'
nnoremap <expr> <Plug>(dial-decrement) '<Cmd>lua require"dial".increment(' .. -v:count1 .. ')<CR>'

let &cpo = s:save_cpo " and restore after
unlet s:save_cpo

let g:loaded_dial = 1

lua/dial.lua

local function dbg(...)
    print(vim.inspect(...))
end

local function increment(addend)
    -- インクリメントする。
    local bufnum, lnum, col, off, curswant = vim.call('getcurpos')
    local line = vim.fn.getline('.')

    local newline = line .. 'a'
    local newcol = #newline
    vim.fn.setline('.', newline)
    vim.fn.setpos('.', {bufnum, lnum, col, off})
end

return {
    increment = increment
}

この状態で適当に nmap <C-a> <Plug>(dial-increment) などマッピングすると上の機能が使えるようになる。
今のところは「カーソルのある行の末尾に a を追加する」だけの機能となっている。

monaqamonaqa

lua/dial.lua のところは、本当なら setpos の効果も確かめたくて

    local newline = line .. 'a'
    local newcol = #newline
    vim.fn.setline('.', newline)
    vim.fn.setpos('.', {bufnum, lnum, newcol, off, curswant})

としたかったのだが、なぜかこうすると

E5100: Cannot convert given lua table: table should either have a sequence of positive integer keys or contain only string keys
E5108: Error executing lua error converting argument 2

というエラーが出ててしまう。 newcol はただの数値のはずなので {bufnum, lnum, newcol, off, curswant} もただの数値の配列になるはずだし、 setpos の引数の要件も満たしているはずなのだが。

monaqamonaqa

原因が分かった。

local bufnum, lnum, col, off, curswant = vim.call('getcurpos')

というところで、正しく bufnum などの変数に値が格納できていなかった。
というのも vim.call('getcurpos') が返すのは「複数の戻り値」ではなく「1つのテーブル(テーブルの中に5つの値が入ってる)」であり、正しくは

local retval = vim.call('getcurpos')
local bufnum = retval[1]
...

としなければならなかった。

しかしそれなら local bufnum, lnum, col, off, curswant = vim.call('getcurpos') の時点でエラーを吐いてほしいし、vim.inspect(bufnum, lnum, col, off, curswant) したときに
{..., ..., ..., ..., ...} ではなく {..., ..., ..., ..., ...}, nil, nil, nil, nil と返ってきて欲しい。
おかげで原因特定にやたらと時間がかかってしまった。

dbg({bufnum, lnum, newcol, off, curswant}) を見て

{ { 0, 15, 5, 0, 5 },
  [3] = 1
}

と出てきてはじめてこのことに気づいた。

nil に対して妙に寛容なLuaの言語仕様、エラーの原因を特定しづらくしているだけのような気がする。

monaqamonaqa

とりあえず簡単に「十進数の整数」を increment/decrement する機能を追加。

lua/dial.lua

local function dbg(...)
    print(vim.inspect(...))
end

-- cursor 上にあるか、もしくは cursor の後にある数字を検索する。
local function search_decimal(line, cursor)
    local idx_start = 1
    local s, e
    while idx_start < #line do
        s, e = line:find("-?%d+", idx_start)
        if s then
            -- 検索結果が見つかれば
            if (cursor <= e) then  -- cursor が終了文字より手前にあればそれが答え
                return s, e, string.sub(line, s, e)
            else
                idx_start = e + 1
            end
        else
            -- 検索結果がなければ nil を return
            break
        end
    end
    return nil
end

-- インクリメントする。
local function increment(addend)

    -- 現在のカーソル位置、カーソルのある行の取得
    local curpos = vim.call('getcurpos')
    local col_cursor = curpos[3]
    local line = vim.fn.getline('.')

    -- 数字の検索、加算後のテキストの作成
    local s, e, text = search_decimal(line, col_cursor)
    if s == nil then
        return
    end
    local num = tonumber(text)
    local newnum = num + addend
    local newtext = tostring(newnum)
    local newline = string.sub(line, 1, s - 1) .. newtext .. string.sub(line, e + 1)
    local newcol = s - 1 + #newtext  -- newtext の末尾に持ってくる

    -- 行編集、カーソル位置のアップデート
    vim.fn.setline('.', newline)
    vim.fn.setpos('.', {curpos[1], curpos[2], newcol, curpos[4], curpos[5]})
end

return {
    increment = increment
}
monaqamonaqa

これから日付や曜日といったパターンを追加していくが、そのためにもう少し構造化したい。たとえば数値と日付は格納すべきデータ型も異なるものの、

  1. 文字列の中から該当するパターンを検索する
  2. マッチした文字列から必要な情報(具体的な日付など)を取得し、具体的な値をデータに格納する
  3. 足し算処理を行い、データを更新する
  4. 更新後のデータを文字列に埋め込む

という処理は共通している。クラスもしくはインターフェースとしてこれらの振る舞いを記述したい。
Rustでいうとこんな感じ。

trait Augend {
    fn find(line: &str, cursor: usize) -> Option<Self>;
    fn add(&mut self, addend: u64);
    fn to_string(&self) -> String;
}

型をベースに設計を決めてる時点で、あんまり Lua っぽくない開発の仕方をしている気がする…

このスクラップは2022/01/01にクローズされました