Read OSS

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) タブページハンドル

リモートハンドル型(BufferWindowTabpage)はいずれも typedef handle_T、つまり実体は int です。ただし意味論的な区別があります。MessagePackでシリアライズする際にはEXT型を使うため、クライアント側でバッファハンドルと通常の整数を区別できます。

defs.h#L47-L53INTERNAL_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種類です。

  1. ディスパッチラッパー: MessagePackの引数をC型にアンパックし、実装を呼び出して戻り値をパックする関数群
  2. Lua Cバインディング: LuaスタックのValueをAPI型に変換し、実装を呼び出してLuaへ結果を返す関数群
  3. キーセット定義: 辞書形式のAPIパラメータに対応した型付き構造体
  4. メタデータ: すべての関数・パラメータ・戻り値の型を記述した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ハンドラ

MsgpackRpcRequestHandlerfast フィールドは、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_linegridext_multigridext_cmdlineext_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つの利点があります。

  1. TUIのレンダリングコードがクラッシュしても、サーバーは生き続ける(未保存のバッファが失われない)
  2. サーバーはヘッドレスで動作でき、外部GUIは同じ方法で接続できる
  3. 複数の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をイベントループへとつなぐ方法を掘り下げます。