NeovimのAPI:RPC、コード生成、UIプロトコル
前提知識
- ›第1〜2回(アーキテクチャ、イベントループ)
- ›RPCの基本概念とMessagePackシリアライゼーションの理解
NeovimのAPI:RPC、コード生成、UIプロトコル
Neovimのアーキテクチャ上で最も重要な設計判断は、エディタ全体の状態を型付きAPIとして外部に公開していることです。バッファの編集、ウィンドウの分割、オプションの変更、ハイライトの操作、これらすべてがAPI関数として呼び出せます。Lua、外部プロセス(MessagePack-RPC経由)、Vimscript、どこからでも統一的にアクセスできます。これを実現しているのがコード生成の仕組みです。各API関数は特殊なアノテーション付きのC言語で一度だけ記述し、Luaジェネレータがそこからディスパッチラッパー、Luaバインディング、機械可読なメタデータを自動生成します。本記事では、アノテーション付きのC関数が呼び出し可能なRPCメソッドへと変換されるまでの全過程を追います。
API型システム
関数の詳細へ入る前に、その関数が扱う型を理解しておく必要があります。src/nvim/api/private/defs.h#L30-L159 で定義されているAPI型システムは、C言語・Lua・MessagePackの三者を橋渡しするシリアライズ可能な型の集合です。
| API型 | C型 | 説明 |
|---|---|---|
Boolean |
bool |
真偽値 |
Integer |
int64_t |
64ビット符号付き整数 |
Float |
double |
倍精度の浮動小数点数 |
String |
struct { char *data; size_t size; } |
長さ付きバイト文字列 |
Array |
kvec_t(Object) |
Objectの動的配列 |
Dict |
kvec_t(KeyValuePair) |
キーと値のペア |
Object |
上記すべてのタグ付きユニオン | 任意の値 |
Buffer |
handle_T (int) |
バッファハンドル |
Window |
handle_T (int) |
ウィンドウハンドル |
Tabpage |
handle_T (int) |
タブページハンドル |
リモートハンドル型(Buffer・Window・Tabpage)はいずれも typedef handle_T、つまり実体は int です。ただし意味論的な区別があります。MessagePackでシリアライズする際にはEXT型を使うため、クライアント側でバッファハンドルと通常の整数を区別できます。
defs.h#L47-L53 の INTERNAL_CALL_MASK は、APIディスパッチを理解する上で欠かせない概念です。すべてのAPI関数は channel_id パラメータを受け取ります。上位ビットが立っている場合(INTERNAL_CALL_MASK)、その呼び出しはプロセス内部から来ています(Vimscriptなら VIML_INTERNAL_CALL、Luaなら LUA_INTERNAL_CALL)。そうでなければ、特定のチャンネル経由のRPC呼び出しです。
API関数のアノテーションとコード生成
API関数の見た目は通常のC関数とほぼ同じで、アノテーションマクロが添えられています。src/nvim/api/vim.c の典型的な例を見てみましょう。
Integer nvim_get_hl_id_by_name(String name)
FUNC_API_SINCE(7)
{
return syn_check_group(name.data, name.size);
}
FUNC_API_SINCE(7) は、この関数が導入されたAPIバージョンを宣言するアノテーションです。その他のアノテーションには以下のものがあります。
FUNC_API_FAST— デファーなしにI/Oスレッド上で直接実行できる安全な関数FUNC_API_REMOTE_ONLY— RPC経由でのみ利用可能で、LuaやVimscriptからは呼び出せないFUNC_API_REMOTE_IMPL— リモート向けに特別な実装を持つ関数
コードジェネレータ src/gen/gen_api_dispatch.lua は、Cのヘッダファイルを解析して関数シグネチャとアノテーションを抽出し、複数の出力ファイルを生成します。
flowchart LR
subgraph "Input"
API_C["api/vim.c\napi/buffer.c\napi/window.c\n..."]
end
subgraph "gen_api_dispatch.lua"
PARSE["C grammar parser"]
HASH["hashy (perfect hashing)"]
end
subgraph "Output"
DW["dispatch_wrappers.generated.h"]
LB["lua_api_c_bindings.generated.c"]
KS["keysets_defs.generated.h"]
META["funcs_metadata.mpack"]
end
API_C --> PARSE
PARSE --> HASH
HASH --> DW
PARSE --> LB
PARSE --> KS
PARSE --> META
生成される成果物は次の4種類です。
- ディスパッチラッパー: MessagePackの引数をC型にアンパックし、実装を呼び出して戻り値をパックする関数群
- Lua Cバインディング: LuaスタックのValueをAPI型に変換し、実装を呼び出してLuaへ結果を返す関数群
- キーセット定義: 辞書形式のAPIパラメータに対応した型付き構造体
- メタデータ: すべての関数・パラメータ・戻り値の型を記述したMessagePackのblobで、クライアントのイントロスペクションに使われます
ヒント: APIメタデータはLuaから
vim.fn.api_info()で参照できます。また:lua vim.print(vim.api)を実行すれば、利用可能なAPI関数の一覧がパラメータ名・型・バージョン情報付きで確認できます。
RPCチャンネルレイヤー
チャンネルはAPI通信のトランスポート層に相当します。ChannelStreamType 列挙型 は5種類のストリームを定義しています。
kChannelStreamProc— 子プロセスの起動(stdio)kChannelStreamSocket— TCPまたはUnixソケットkChannelStreamStdio— プロセス自身のstdin/stdout(--embed時に使用)kChannelStreamStderr— stderr出力kChannelStreamInternal— プロセス内Luaコールバック
チャンネルがデータを受信すると、MessagePackストリームが解析されてディスパッチされます。rpc_start() はチャンネルのRPC状態を初期化します。具体的には、アンパッカーの生成、リクエストIDカウンタを1に設定、そして rstream_start(out, receive_msgpack, channel) による出力ストリームの読み取り開始を行います。
sequenceDiagram
participant Client as RPC Client
participant Chan as Channel
participant Unpack as Unpacker
participant Disp as Dispatch
participant Handler as API Handler
Client->>Chan: MessagePack bytes
Chan->>Unpack: receive_msgpack()
Unpack->>Disp: Decoded request [type, id, method, args]
Disp->>Disp: msgpack_rpc_get_handler_for(method)
alt fast handler
Disp->>Handler: Execute immediately
Handler->>Client: Response
else deferred handler
Disp->>Disp: Queue to main_loop.events
Note over Disp: Processed as K_EVENT
Disp->>Handler: Execute in editor mode
Handler->>Client: Response
end
メソッドのディスパッチには完全ハッシュ関数を使います。msgpack_rpc_get_handler_for() は、メソッド名の文字列を method_handlers[] 配列のインデックスにマッピングする生成済みハッシュ関数を呼び出します。各エントリは MsgpackRpcRequestHandler 型で、関数ポインタとfast/deferredフラグを持っています。
FastハンドラとDeferredハンドラ
MsgpackRpcRequestHandler の fast フィールドは、NeovimのAPIにおける正確性を担保する上で最も重要な区別の一つです。
flowchart TD
REQ["Incoming RPC Request"] --> CHECK{handler.fast?}
CHECK -->|"true (FUNC_API_FAST)"| IMMEDIATE["Execute on I/O thread\nDuring uv_run()"]
CHECK -->|"false (default)"| DEFER["Queue to main_loop.events"]
DEFER --> KEVENT["Processed as K_EVENT\nin state_enter()"]
KEVENT --> EXEC["Execute in editor mode"]
IMMEDIATE -.->|"⚠️ Must not modify\neditor state"| NOTE1["Only read-only or\nthread-safe operations"]
EXEC -.->|"✅ Safe to modify\neverything"| NOTE2["Buffer text, options,\nUI, autocmds"]
Fastハンドラはイベントループがuv_run()でポーリング中に即時実行されます。エディタの状態に触れない読み取り専用の操作——nvim_get_current_line() やオプション値の確認など——に適しています。イベントキューを経由しないため、レイテンシが低くなります。
Deferredハンドラ(デフォルト)は main_loop.events にキューイングされ、第2回で解説した K_EVENT メカニズムを通じて処理されます。これにより、エディタの完全な状態が利用可能な状態で実行され、autocmdのトリガーや再描画、副作用を安全に行えます。
この区別を誤ると、発見しにくいバグを引き起こします。Fastハンドラがバッファのテキストを誤って変更すると、libuvのコールバックがまだ実行中の状態で変更が加わり、状態が壊れる可能性があります。FUNC_API_FAST が明示的なオプトインを要求しているのはそのためです。
UIプロトコルとグリッドイベント
NeovimのUIプロトコルは src/nvim/api/ui_events.in.h のイベント群として定義されています。このファイルは直接コンパイルされません。gen_api_ui_events.lua に解析され、サーバー側の呼び出し関数とクライアント側のハンドラディスパッチの両方が生成されます。
このイベント群はUIのレンダリングに必要なすべてを定義しています。セル更新用の grid_line、カーソル位置の grid_cursor_goto、モード切り替えの mode_change、ハイライト属性の hl_attr_define など、数十種類のイベントが含まれます。
RemoteUI 構造体 はクライアントごとの状態を管理します。具体的には、スクリーンのサイズ、サポートする拡張機能、MessagePackのパッカーバッファ、バッチイベントシリアライゼーションのブックキーピングなどです。サーバーは最大16の接続UIを配列で管理します。
#define MAX_UI_COUNT 16
static RemoteUI *uis[MAX_UI_COUNT];
ext_linegrid・ext_multigrid・ext_cmdline・ext_popupmenu といったUI拡張は、段階的なcapabilityネゴシエーションを可能にします。シンプルなターミナルUIは基本的なgridプロトコルのみを使い、高機能なGUIは構造化されたcmdlineやpopupmenu、メッセージイベントを要求できます。
flowchart TB
subgraph "Server"
ED["Editor Core"]
UI_C["ui.c — UI dispatch"]
PACK["MessagePack packer"]
end
subgraph "UI Clients"
TUI["TUI Process"]
GUI1["External GUI 1"]
GUI2["External GUI 2"]
end
ED -->|"grid_line, cursor_goto,\nmode_change, ..."| UI_C
UI_C -->|"per-client packing"| PACK
PACK -->|"stdio"| TUI
PACK -->|"socket"| GUI1
PACK -->|"socket"| GUI2
クライアントとサーバーの分離
第1回で見たとおり、ターミナルで nvim を起動するとプロセスは分割されます。ui_client_start_server() は --embed フラグ付きの子Nvimプロセスを起動し、stdioを通じて接続します。
args[args_idx++] = xstrdup("--embed");
親プロセスはUIクライアントになります。MessagePack-RPCチャンネル経由で子サーバーにアタッチし、ターミナルのI/Oを転送します。エディタの実処理は子プロセスが担います。この分離構造には3つの利点があります。
- TUIのレンダリングコードがクラッシュしても、サーバーは生き続ける(未保存のバッファが失われない)
- サーバーはヘッドレスで動作でき、外部GUIは同じ方法で接続できる
- 複数のUIが同じサーバーに同時にアタッチできる
main.c の352行目にある ui_client_channel_id 変数は、このプロセスがUIクライアントかどうかを追跡しています。非ゼロであれば ui_client_run() が呼び出されて返らなくなり、サーバーからUIイベントを読み取ってターミナルにレンダリングするループに入ります。
次回予告
APIとUIプロトコルが外部インターフェースを担っていることを見てきました。しかしNeovimで最も変革的な統合が起きているのは内部、つまりLSP・treesitter・diagnosticsを動かすLuaランタイムです。次回は、executor.c のC-Luaブリッジが vim.* 名前空間を構築する仕組み、二つの世界を行き来する型変換の詳細、そして vim.schedule() と vim.uv がLuaをイベントループへとつなぐ方法を掘り下げます。