コンテンツにスキップ

Language Server — Rigor向けインプロセスRuby LSP

Status: Draft. 契約に対して将来ADRが起票されたときにのみ取って代わ られる。

ADR-0はLSP統合を先送りし、CLIファーストの推論 エンジンを成熟させた。エディタモードv1(docs/design/20260516-editor-mode.md) はCLIシェルアウトのフロアであり、今日機能する。本ドキュメントは、 そのフロアを「キーストローク高速」フィードバックループへと変える インプロセスRuby Language Serverを設計する。これにより、キースト ロークごとにRuby VM/RBS env起動コストを再消費することがなくなる。

枠組みの決定、言語の比較、アーキテクチャ三者比較の議論はここでは繰り 返さない。本ドキュメントを生んだチャットスレッドを参照されたい。本 ファイルは決定を束ねるものである。

  • アーキテクチャ: B(インプロセスRuby LSP)。1つのLSPプロセスが Rigor::Analysis::Runner、プラグイン、Environment、RBSロード、 Ractorプールをホストする。リクエストごとの作業はバッファごとの推論 のみである。
  • 言語: Ruby。解析器と同じランタイム。IPCなし、シェルアウトなし、 言語横断の型マーシャリングなし。
  • ライブラリ: language_server-protocol gem(薄い)。JSON-RPC フレーミング+LSP型セットを提供する。Solargraph/RuboCop LSP/Steep はすべてこれを使う。Rigorはruby-lspのアドオンフレームワーク(Rigor には合わないShopifyスタイルのライフサイクルを前提とする)の中で生き るのではなく、自身のディスパッチャー、ライフサイクル、メッセージ ルーティングを所有する。
  • CLI表面: rigor lspサブコマンドrigor checkrigor type-of と同じgem、同じバイナリ、同じ設定発見。今日公開する別gemはない。v1 のLSPはrigor gem自体の一部である。パッケージ形状(バンドルvs スタンドアロンのrigor-lsp gem vs ruby-lsp-rigorアドオン)は ADR-19で、問題を再び 開く可能性のあるトリガー条件とともに決定される。
  • トランスポート: stdio JSON-RPC。v1にはTCP/IPC/Unixソケットは ない。

なぜアーキテクチャBがRigorにとってA/Cに勝るか

Section titled “なぜアーキテクチャBがRigorにとってA/Cに勝るか”

ボトルネックはLSPプロトコルのオーバーヘッドではない。Ruby VM起動 (約150ms)+Environment.for_project(ウォームで約100-300ms、コール ドで1000ms以上)+プラグインロードである。エディタモードv1のCLIシェル アウトはキーストロークごとにそのコストを支払う。インプロセスは一度だ け支払い、セッションを通じて償却する。

A(CLIシェルアウト)B(インプロセスRuby)C(多言語+Rubyデーモン)
リクエストあたりウォール時間500ms–1.5s30–200ms50–250ms
解析器との相互運用サブプロセス引数直接requireJSON-RPC/msgpack
リクエスト横断のプラグインファクト共有不可デーモンAPIが必要
Ractorプール再利用不可能(ワンショット)可、デーモン側
コードベースフットプリント0(エディタモードv1)LSPサーバーLSPシェル+デーモン+IPCスキーマ
配布単一gem単一gem単一静的バイナリ+gem

アーキテクチャCはプロトコル側のパフォーマンスとバイナリ配布で勝つが、 Rigorが今日気にする他のすべての軸では負ける。LSPプロトコルのレイテン シが(その兆候はないものの)ボトルネックになった場合は、プロトコル シェルとしてGoまたはRustを使うCを再検討する。

Terminal window
rigor lsp [--transport=stdio] [--log=PATH] [--config=PATH]
  • --transport=stdio(デフォルト。v1で受け付けられる唯一の値)。TCP /Unixソケットトランスポートは具体的な需要が出るまでキューイングさ れる。
  • --log=PATHはLSPワイヤログ+サーバー側デバッグ出力を書き込む。 未設定の場合、サーバー側ログはstderrに行く(クライアントは window/logMessage経由でルーティングする)。
  • --config=PATHrigor check --config=PATHをミラーする。LSPは未設 定の場合Configuration.discoverを使う(同じコードパス)。

位置引数はない。LSPサーバーは「paths」を持たない。クライアントが textDocument/didOpen経由で何が開かれているかをサーバーに伝える。

リクエスト → 内部APIのマッピング

Section titled “リクエスト → 内部APIのマッピング”
LSPメソッド方向Rigor内部備考
initializeC→SEnvironment.for_project+プラグイン#prepare+プリパスをブートストラップアドバタイズされたケイパビリティを返す。プロジェクトルートはrootUriworkspaceFoldersから。
initializedC→Sno-opオプションのworkspace/didChangeWatchedFiles登録をトリガーする。
shutdownC→Sランナー解放、ワーカードレインサーバーはexitまで生存する。
exitC→Sexit 0プロセス終了。
textDocument/didOpenC→S仮想ファイルテーブル{uri => bytes}診断発行をトリガーする。
textDocument/didChangeC→S仮想テーブルを変異デバウンスされた診断発行をトリガーする。
textDocument/didSaveC→Sv1ではno-op診断はdidChangeによりすでに新鮮。
textDocument/didCloseC→S仮想テーブルからエントリー削除URIに対し空の診断を発行してインラインマーカーをクリアする。
textDocument/publishDiagnosticsS→CRunner.run(buffer:)Result#diagnostics → LSP Diagnostic[]ファイルごとの出力。ダーティなファイル1つにつき通知1つ。
textDocument/hoverC↔S位置におけるScope#type_ofSource::NodeLocatorScopeIndexer)— 既存のrigor type-ofのコアMarkdownボディを返す。
textDocument/definitionC↔S(先送り)Reflectionシンボルインデックススライス7以降。
textDocument/documentSymbolC↔SPrism ASTを走査してClassNodeModuleNodeDefNodeを収集 → LSP DocumentSymbol[]
workspace/didChangeConfigurationC→SConfiguration.discover再読み込み+Environment再構築キャッシュ済みプリパスを破棄。
workspace/didChangeWatchedFilesC→Sファイルごとのキャッシュ無効化「プロジェクトコンテキストの更新」セクションを参照。

それ以外のメソッドはServerCapabilitiesでアドバタイズされない。問い 合わせたクライアントはMethodNotFoundを受け取る。スコープ外のメソッ ドは「v1スコープ外」セクションで列挙される。

LSPサーバーはセッションごとにBufferTableを保持する。 DocumentUriをキーとする。

class BufferTable
# uri -> { bytes: String, version: Integer, dirty: Boolean }
end
  • didOpenはエントリーを作成する。
  • didChangebytesを変異させversionを増やす。診断発行が完了する までdirty: true
  • didCloseはエントリーを削除する。URIの診断は空の発行でクリアされる。

診断実行が発火すると、サーバーはダーティなエントリーごとにBufferBinding を1つ実体化する。

BufferBinding.new(
logical_path: uri_to_project_path(uri),
physical_path: write_tempfile(bytes)
)

パスマッピング(uri_to_project_path)はfile://...をランナーが期待 するプロジェクトルート相対パスに正規化する。Windowsでは、URIデコード がドライブレターの畳み込みを担当する。このケースに対するv1仕様は 「オープンクエスチョン」セクションにある。

なぜインメモリの{path => bytes}パーサオーバーライドではなくテンポ ラリファイルを使うのか? RunnerWorkerSession/プリパススキャナ はBufferBinding.resolveを通じてすでに物理パスからパースする。LSPバ ッファをテンポラリファイル経由でルーティングすればその契約をビット単 位で再利用できる。新たなパーサエントリーポイントなし、維持すべき第二 のコードパスなし。テンポラリファイルはDir.tmpdir配下に置かれ、バッ ファエントリーが削除されたときにunlinkされる。

  • LSPはサイズNのRactorプール1つを起動する(parallel.workers:RIGOR_RACTOR_WORKERSrigor checkをミラー)。
  • ワーカーは最初のリクエスト時に遅延ウォームアップされるのではなく、 initialize時にEnvironment+プラグインで事前ウォームアップされる。 セッションは長命(分から時間オーダー)であり、コールドスタート税は ちょうど1回支払われる。
  • publishDiagnosticsリクエストはワーカー1つにディスパッチされる。 プールの既存のワーカーごとのレポーターとFactStoreはrigor checkプー ルモードと同様に機能し続ける。
  • hoverdocumentSymbolリクエストはメインRactorでインラインで実行 できる(安価。バッファごとの推論なし)。
  • キャンセル: LSPの$/cancelRequestはv1ではリクエストごとのキャンセル フラグの設定で尊重される。ワーカーはスコープインデックス構築ステップ 間でそれをチェックする。粒度は粗い(実行中リクエスト1つにつきキャン セルポイント1つ)。きめ細かいASTウォークキャンセルは先送りである。

エディタモードv1はバッファごとのワンショットコストがプールウォーム アップに支配されるためworkers: 0を強制する。LSPはそれを反転させる。 プールは一度ウォームアップされて生存し続けるため、リクエストごとの コストは本来あるべき場所(推論のみ)に着地する。

プロジェクトコンテキストの更新

Section titled “プロジェクトコンテキストの更新”

プロジェクト全体のプリパス(SyntheticMethodScannerProjectPatchedScanner、プラグインの#prepare、依存ソースウォーカー) は高価である(プロジェクト規模に応じて数百ミリ秒から数秒)。キース トロークごとに再実行されてはならない(MUST NOT)。

セッションはコンテキスト世代カウンタと派生スナップショットを保持 する。

class ProjectContext
attr_reader :generation, :synthetic_method_index,
:project_patched_methods, :plugin_registry,
:environment
end

無効化ルール。

イベントアクション
プロジェクト.rbファイルに対するworkspace/didChangeWatchedFilesファイルごとの合成メソッド/プロジェクトパッチ寄与を無効化、影響を受けるインデックススライスを再構築
.rigor.ymlGemfile.lockに対するworkspace/didChangeWatchedFiles世代を増やし、コンテキスト全体を再構築
workspace/didChangeConfiguration世代を増やし、再構築
開いているバッファに対するdidChange無効化なし — バッファは仮想であり、ディスク上にない。プリパスはBufferBinding経由ですでに仮想バイトを参照する

バッファのプリパスは診断発行時に常に仮想ファイルテーブルに対して再実 行される。単一ファイルスコープでは十分安価である。プロジェクト全体の 再実行はworkspace/didChangeWatchedFilesの背後にゲートされる。

クライアントがworkspace/didChangeWatchedFilesをサポートしない場合 (最小限のクライアントなど)、LSPは安全弁としてN=20で「N回ごとのリ クエストでコンテキストを再構築する」にフォールバックする。粗いが正し い。

LSPはサーバープッシュのtextDocument/publishDiagnosticsを要求する。 サーバーは以下のときに発行する。

  • didOpen時 — 開かれたバッファに対する新鮮な診断。
  • didChange時 — 最後のキーストロークから200msデバウンス。新しい didChangeごとにタイマーがリセットされる。高速タイピング中の発行 ストームを防ぐ。
  • didClose時 — URIに対する空の診断配列(インラインマーカーをクリア)。

バッファごとのスコープ: 変更されたバッファのみが新鮮な発行を受ける。 これはエディタモードv1の単一ファイルスコープに一致する。ファイルごと の診断キャッシュが着地したとき(キューイング済み。ROADMAPの「エディ タ/IDE統合」セクションを参照)、LSPはプロジェクトスコープ発行に安価 に昇格できる。

重要度プロファイル+ルールごとのオーバーライドはrigor checkと同様に 適用される。LSP DiagnosticSeverityのマッピング。

Rigor Diagnostic#severityLSP DiagnosticSeverity
:errorError (1)
:warningWarning (2)
:infoInformation (3)
:hintHint (4)

LSP Diagnosticsourceフィールドは"rigor"codeはルール識別子 ("call.undefined-method""flow.always-raises"、…)。dataは プラグインソースファミリー(:builtin"plugin.activerecord"/…)を 運ぶため、後でクライアント側フィルタを配線できる。

v1でアドバタイズされるケイパビリティ

Section titled “v1でアドバタイズされるケイパビリティ”
{
textDocumentSync: {
openClose: true,
change: TextDocumentSyncKind::FULL # incremental queued
},
diagnosticProvider: {
interFileDependencies: false, # single-file scope
workspaceDiagnostics: false
},
hoverProvider: true,
documentSymbolProvider: true,
positionEncoding: "utf-16" # LSP default; UTF-8 queued
}

change: FULLを先に出荷する。インクリメンタルな変更処理はUTF-16コード ユニットに対する行/列追跡を必要とし、これは些細でない正確性の作業だ からである。FULLはキーストロークごとにバッファ全体を再送する。ネット ワークはローカルのstdioであり帯域は無関係である。コストはランナーに あり、トランスポートにはない。

インクリメンタルな変更処理はスライス9以降にキューイングされる。

language_server-protocol(mtsmfm)は以下を提供する。

  • stdiosocket経由のJSON-RPCフレーミング。
  • Ruby Data形状の値クラスとしての完全なLSP型セット。
  • 最小限のLanguageServer::Protocol::Transport::Stdioの読み手/書き手。

提供しないもの。

  • サーバーライフサイクル。我々がLanguageServer::Server(状態機械: uninitialized → initialized → shutdown → exit)を所有する。
  • リクエストディスパッチャー。我々がメソッドシンボル → ハンドラのハッ シュを所有する。
  • ワーカープール。我々がRigorのRactorプールに直接バインドする。

ruby-lsp(Shopify)は3つすべてを提供するが、特定のアドオンライフ サイクルと、単一ツールLSPには冗長な意見の強い「extensions register here」表面を仮定する。Rigorは多拡張足場を必要としない。我々はライフ サイクルを完全に制御できる最小限のプロトコル層を望む。よって薄い選択 である。

各スライスはspec付きで自身のコミットで出荷される。エディタモードv1の 7スライス分割と同じ規律である。

  1. rigor lsp CLIサブコマンドスタブ--transport=stdioを受け 付け、ケイパビリティスケルトンを表示し、shutdownexitで終了 する。実際の解析はまだない。Spec: LanguageServer::Serverを通じて 最小のinitializeshutdownexitシーケンスをディスパッチ し、応答形状をアサートする。
  2. Rigor::LanguageServer::Serverライフサイクル。状態機械、stdio 上のJSON-RPCディスパッチャー、ケイパビリティネゴシエーション。フレー ミングにlanguage_server-protocolを再利用する。
  3. BufferTabledidOpendidChangedidClose。仮想ファイル テーブルを保持する。診断はまだない。
  4. didChange時のpublishDiagnostics(200msデバウンス)BufferBindingを実体化し、バッファモードでRunnerを実行し、 DiagnosticをLSP形状に変換し、プッシュする。エンドツーエンドで ユーザーに見える最初の成果。
  5. textDocument/hoverrigor type-ofのコア(スコープインデック ス+NodeLocatorScope#type_of)をラップする。型+RBS消去形を 含むMarkdownホバーボディを返す。
  6. textDocument/documentSymbol。Prism ASTを走査してClassNodeModuleNodeDefNodeを収集 → LSP DocumentSymbol[]
  7. workspace/didChangeWatchedFiles+ProjectContext無効化。ファイ ルシステムイベントが影響を受けるインデックススライスを破棄する。 プリパスはインクリメンタルに再構築される。
  8. Ractorプール統合。LSPはinitialize時にプールを起動する。リク エストごとの診断はプールにディスパッチされる。hoverdocumentSymbolはメインRactorのままとする。
  9. (先送り)textDocument/definition — FILE:LINEをキーとする Reflection側シンボルインデックスを必要とする。
  10. (先送り)インクリメンタルなdidChange — UTF-16オフセット 管理+行/列変換。

スライス8のあとで、エディタモードv1がすでに目標としていた「キース トローク高速のリント+ホバー型」ループに対して、ただし10倍の応答性で v1 LSPは機能完成となる。

  • textDocument/completion(実質的 — 別途補完エンジン設計が必要。本 ドキュメントには何もブロックされない)。
  • textDocument/codeAction(リファクタリング — 別の問題)。
  • textDocument/formatting(RuboCopの仕事)。
  • textDocument/rename(プロジェクト全体のシンボルインデックスが必要)。
  • textDocument/semanticTokens(装飾的、オプション)。
  • textDocument/inlayHint(装飾的、オプション)。
  • マルチルートワークスペース(v1は単一ルートのみ)。
  • TCP/ソケットトランスポート。
  • インクリメンタル同期(スライス10としてキューイング)。
  • リクエストごとより細かいキャンセル(キューイング)。
  • Windowsパスエンコーディング。LSPのURIはWindowsで file:///C:/foo/bar.rbをデコードする。プロジェクト相対パスのマッピ ングはドライブレターのケース+パス区切り文字の畳み込みを処理する必要 がある。v1は期待される形を文書化するが、LSPのWindows CIはv1では計画 されていない。
  • ロギングポリシー。サーバー側ログ書き込みは2つに分かれる。プロト コルログ(クライアントへ送られるLSP window/logMessageイベント)と 運用ログ(--log=PATH下に書かれるファイル)。--logがセットされた ときは両方にミラーすることを推奨する。さもなくばファイルログは stderrへ行き、クライアントはshowMessage経由で:errorレベルのイ ベントだけを見る。
  • 設定の再読み込みworkspace/didChangeConfigurationのペイロード 形式はクライアント固有である。v1はペイロードを無視して Configuration.discoverを再実行する。特定クライアント(Neovimの lspconfig、VSCodeのRigor拡張)が独自形状を望む場合、後で --workspace-config-formatフラグが現れるかもしれない。
  • ホバーコンテンツ形式。LSPのHover#contentsMarkupContent { kind, value }を受け付ける。v1は型+RBS消去行に対 する```rubyコードブロックを伴うkind: "markdown"を出荷す る。MarkupKind::PlainTextのみをサポートするクライアント向けプレー ンテキストフォールバックはキューイングされている。
  • initializationOptionsの形。v1は存在すればconfig_path:cache_path:を読む。両方ともオプション。このための正確なJSON-Schema はスライス1の着地時に最終決定される。
  • 単一バッファvsプロジェクトスコープ診断。LSPはエディタモードv1 の「オプションA」(単一ファイルスコープ)を継承する。ファイルごとの 診断キャッシュが着地したとき(ROADMAPの「エディタ/IDE統合」セク ション)、LSPはファイル保存時にプロジェクト全体の診断を発行できる。 CLI形状は前方互換である。

これらはスライス8のあと、現行ラップトップ(8コア、32GB)上の5Kファイ ルプロジェクトに対するウォームセッションでの目指すべき定常状態目標で ある。

操作目標ウォール時間パス
コールドスタート(initialize → 最初の発行)< 3sEnvironment構築+プリパス
didChangepublishDiagnostics< 250ms(p50)、< 500ms (p95)デバウンス+単一ファイル推論
hover< 100ms (p95)スコープインデックス+type_of
documentSymbol< 50ms (p95)Prism走査
定常状態メモリ< 600 MBRBS env+Ractorプール+Nバッファ

コールドスタート予算はRBS env構築に支配される。キャッシュヒットの ウォームスタートは < 1.5sのはず。didChange予算は単一ファイルスコー プ(オプションA)を仮定する。オプションB(プロジェクトスコープ+ ファイルごとの診断キャッシュ)が利用可能になったときには、p95は実質 的に締まるだろう。

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.