👑

えっ!NimでNeovim用プラグインを!?

2022/12/18に公開

この記事は Vim Advent Calendar 2022 の18日目です。

えっ!NimでNeovim用プラグインを!?

できらぁ!

はい。ということで仕組みから順に話していこうと思います。

Nim langについては特に解説しません。
どちらかというとLuaJITのFFIに注目した話で、GoやRustなどの他の言語にも応用できるはずです。

例としてこのプラグインを使います。
https://github.com/uga-rosa/nim-example.nvim
アッカーマン関数を計算するだけのプラグインです。
やりすぎるとNeovimごと落ちるのでお気を付けください。

FFI Library

NeovimのLua runtimeはLuaJITです。
LuaJITは基本的にはLua5.1互換であり、その扱いで問題はありません。
しかしLuaJIT固有の機能というのも幾つかあります。
今回取り上げるFFI Libraryはその一つです。

FFIとはForeign Function Interfaceの略であり、プログラミング言語間の連携を行うための機能です。
LuaJITのFFIのターゲットはC言語です。

Nimじゃないやん

はい。ダメじゃんと思いますよね。
実はNimはトランスコンパイラなのです。
デフォルトでNim -> C -> binaryの過程でコンパイルしています。
つまり、理論上はNimで定義した関数をLuaJITから使えるはずです。

バックエンドにはjavascriptも使えるらしいのですが、Vim/Neovimでそっちを使いたいのならば素直にdenops.vimを使いましょう。
あえてNimで書かなくともそのままで書きやすい言語ですしね。

FFI libraryを使うには

とりあえず公式を見てみましょう。
英語ですが例を多めに載せてくれているので読みやすいかと思います。

どうやら、Cの共有ライブラリ(*.so/dll)から読み込む形で用いるようです。
ということで、Nimのソースからライブラリをコンパイルする方法を調べてみます。

コンパイル

Nimは公式のドキュメントを見るのが一番正確で手っ取り早いです。
コンパイラのページはここですね。
以下の記述が見つかりました。

--app:console|gui|lib|staticlib
            generate a console app|GUI app|DLL|static library

つまり、次のようにコンパイルすればいいようです。

nim c --app:lib path/to/foo.nim

nim-example.nvimではこんな感じのMakefileを書きました。

Nimのコード

さて、Nim側でもう一つやっておかないといけないことがあります。

肝心のNimのソースコードはこちらになるのですが、関数定義の最後になにか付いていますよね。

1|  proc ackermann*(m, n: int): int {.exportc, dynlib.} =

この {} で囲まれた部分はプラグマ(pragmas)というもので、Nimの強力な言語機能の一つです。

Pragmas are Nim's method to give the compiler additional information / commands without introducing a massive number of new keywords. Pragmas are processed on the fly during semantic checking. Pragmas are enclosed in the special {. and .} curly brackets. Pragmas are also often used as a first implementation to play with a language feature before a nicer syntax to access the feature becomes available.
https://nim-lang.org/docs/manual.html#pragmas

今回使っているプラグマは以下の二つで、(今回の用途では)セットで用いるものです。
exportc pragmas
dynlib pragmas for export

これらのプラグマを付けないとプロシージャをライブラリにエクスポートできないので気を付けましょう。

Luaから読み込む

これで準備は整いました。
nim-example.nvimでは、makeによって./build/libex.(so|dll)がビルドされてます。

これをLuaJITから読み込んでみましょう。
こちらにコメントを追加する形で、簡単に説明します。

local uv = vim.loop
local fn = vim.fn

local ffi = require("ffi")

-- 絶対パスの生成・ライブラリの読み込み
-- ffi.load()は絶対パスでないとシステム依存の方法で解決するので、今回の用途には適さない。
local this_file_path = uv.fs_realpath(debug.getinfo(1, "S").source:sub(2))
local root_path = fn.fnamemodify(this_file_path, ":h:h:h")
local lib_path
if ffi.os == "Windows" then
  lib_path = fn.expand(root_path .. "/build/libex.dll")
else
  lib_path = fn.expand(root_path .. "/build/libex.so")
end

local libex = ffi.load(lib_path)

-- Cでの定義。ここで書いたものしか使えない。
ffi.cdef([[
int ackermann(int m, int n);
]])

local M = {}

function M.nim_ackermann(m, n)
  -- libexはffi.load()の戻り値
  return libex.ackermann(m, n)
end

-- 比較用のpure lua version
local function ackermann(m, n)
  if m == 0 then
    return n + 1
  elseif n == 0 then
    return ackermann(m-1, 1)
  else
    return ackermann(m-1, ackermann(m, n-1))
  end
end

function M.lua_ackermann(m, n)
  return ackermann(m, n)
end

return M

全体の流れとしては

  1. ffi.load()でライブラリを読み込む。
  2. ffi.cdef()で使いたい関数の定義を書く。
  3. ffi.load()の戻り値を介してその関数を使う。

といった感じです。

公式のチュートリアルも参考になると思います。

比較

では最後に、Nim versionとLua versionのアッカーマン関数の速度を比較してみて終わりにしましょう。

:NimAckermann 3 10
" -> 8189
" -> time: 41.04 ms
:LuaAckermann 3 10
" -> 8189
" -> time: 106.195 ms

Nimの方が倍以上速いですね!
......
倍ちょっと程度で済むLuaJITの速度が異常だというオチでした。チャンチャン。

おまけ

最新のLua5.4.3でも比較してみましょう。

ackermann.lua
local function ackermann(m, n)
  if m == 0 then
    return n + 1
  elseif n == 0 then
    return ackermann(m-1, 1)
  else
    return ackermann(m-1, ackermann(m, n-1))
  end
end

local start = os.clock()
print(ackermann(3, 10))
print("time:", (os.clock() - start) * 1000, "ms")
$ lua ackermann.lua
8189
time:   702.039 ms

LuaJITの6倍以上の時間になりました。
オリジナルのLuaもかなり早くなっていますが、まだLuaJITの方が早いですね。

GitHubで編集を提案

Discussion