コンテンツにスキップ

LSP v2 — 型を意識したhover + completionの設計

Status: Draft。 20260517-language-server.md (LSP v1、v0.1.6で着地)のフォローアップであり、2つの表面を型を意識した 挙動へ拡張する。すなわち、よりリッチなhoverと、textDocument/completion の最初の一手である。

LSP v1のtextDocument/hoverは最小限のmarkdownボディ (type:erased:node:)を返す。動作はするものの、まだ解析器の 持つ完全な型情報を活かしきれていない。メソッド呼び出しのレシーバー型、 RBSコメント、シグネチャ、定数定義箇所へのsource-of-truthリンクなどである。 Completionはv1では完全に存在しない(設計ドキュメントの §「Out of scope for v1」でキューに積まれている)。 両方のギャップは、エディタ利用者が自然に感じる次の一手のUX作業である。

このドキュメントが設計するのは:

  1. Hoverの強化 — ノードクラスでディスパッチするhoverレンダリングにより、 形状ごとに型に関連する情報を表に出す。
  2. textDocument/completion v1 — .の後のメソッド補完と、 ::の後の定数パス補完。いずれも推論された型または宣言された型に駆動される。

textDocument/signatureHelpは自然な兄弟として言及されるが、別スライスに キューイングされる(completionと相補的だが、その表面は独立している)。

  • Hoverはインラインのmarkdownボディのままとする(LSP Hover.contentskind: "markdown")。v2ではrangeフィールドは持たない — エディタはカーソル位置をアンカーとして使う。
  • ノードクラスごとのレンダリングを新しいHoverRendererコラボレータで 行い、Prismのノードクラスでディスパッチする。未知の形状については slice-5のデフォルトボディを維持し、CallNode / ConstantReadNode / ConstantPathNode / LocalVariableReadNode / InstanceVariableReadNode / リテラルキャリアについて特殊化する。
  • Completionのスコープv1: .の後のメソッド補完と、::の後の 定数パス補完。裸の名前による補完(ローカル変数 + 暗黙のself上のメソッド)と ハッシュキー補完(HashShapeキャリア)はv2のフォローアップにキューイングする。
  • トリガー文字: .::(LSP capabilityの completionProvider.triggerCharacters: [".", ":"]::の2番目の :がトリガーとなり、1文字前を覗き見る)。
  • メソッド列挙はReflection.instance_definition / singleton_definition経由 — Rigorの既存のRBSクエリ表面である。 新しい公開APIは追加しない。
  • CompletionItemのdetailフィールドはRBSシグネチャであり、 rigor sig-genと同じ方法でレンダリングする。シグネチャ1行、 kebab-caseの精製は展開する。
  • サーバーサイドでのファジーマッチングは行わない。LSPクライアント (VSCode / Neovim / Emacs)がCompletionItem[]をユーザーの入力プレフィックスに 対して自前でフィルタする。サーバーは候補集合をまるごと返し、クライアントに フィルタさせる。こちらの方がシンプルで安価であり、エディタごとの ファジーマッチ設定を尊重できる。
type: <Type#describe>
erased: <Type#erase_to_rbs>
node: Prism::IntegerNode

デバッグ用途としては有用だが、ユーザー向けのツールチップとしては弱い。 情報密度が低く、認知地図(「カーソル下のものにとってこの型は何を意味するのか」) が欠けている。

ノードごとのレンダリング行列

Section titled “ノードごとのレンダリング行列”
ノードクラスhoverボディの形状
Prism::CallNodeobj.foo(args)レシーバー型 + メソッドシグネチャ(パラメータ + 戻り値)+ RBSコメント(あれば)+ ソース位置へのリンク。
Prism::ConstantReadNode / Prism::ConstantPathNode解決済みのクラス/モジュールFQN + シングルトン型 + クラスのRBSコメント + ソース位置へのリンク。
Prism::LocalVariableReadNode / LocalVariableWriteNode変数名 + 推論/絞り込み済みの型 + 直近のバインディングの行。
Prism::InstanceVariableReadNode / InstanceVariableWriteNode@fooスコープのインスタンスコンテキスト絞り込みによるivar型 + 囲んでいるクラス。
Prism::SymbolNode:fooリテラル値 + キャリア(Constant<:foo>)。
Prism::IntegerNode / FloatNode / StringNode / RegularExpressionNodeリテラル値 + キャリア +(精製済みのStringについては)精製名。
Prism::ArrayNode / HashNodeキャリアの形状(Tuple<...> / HashShape<...>)と、要素型を1行ずつレイアウトしたもの。
defaultslice-5のボディ(type: / erased: / node:)。

レンダラーはノードに対するcaseディスパッチを持つ単一のクラスで、 各分岐は短い(markdown構築は1〜3行)。新規コードの総量は約150行。

メソッド呼び出し(obj.foo(args):

# Receiver
String
# Method
def upcase: () -> String
# Defined in
core (ruby/rbs)

1行目はレシーバー型のdescribe形式。2行目はRBSシグネチャで、 Reflection.instance_method_definition(class_name: receiver.describe, method_name: node.name)で参照し、sig-genが使っているのと同じ消去パスを 通してレンダリングする(v1は単一オーバーロード表示。複数オーバーロードの 対応はフォローアップ)。

3行目はソース帰属を示す。RBS定義にlocation.buffer.nameがあればそれを 表示し、そうでなければEnvironment::Reflectionのパス分類に基づいて “core (ruby/rbs)” / “bundled (gem-ships sig/)” / “project sig” のいずれかに フォールバックする。

定数:

# Constant
Foo::Bar
# Type
singleton(Foo::Bar)
# Defined in
lib/foo/bar.rb:3

定数のFQNはqualified_name_of(node)から得る(すでに DocumentSymbolProviderにある)。型は型システムが付与した Type::Singletonキャリア。ソース位置は Reflection.instance_definition(class_name).declarations.first.locationから 取得する。

ローカル変数:

# Local
results
# Type
Array[Integer]
# Bound at
lib/example.rb:12

カーソル位置で絞り込まれた型は、Scope#type_ofがすでに返しているものである。 Bound-atはスコープ内の直近の代入箇所であり、スコープインデクサが LocalVariableWriteNodeについて既に追跡している。

精製による絞り込み:

値の絞り込み済み型が精製 (Refined[non-empty-string]Difference[Integer, -1..-1])である場合、 hoverは正準な精製名と基底型を併記して表に出す:

# Type
String (non-empty-string)

これはUXとして価値が高い。絞り込みは解析器の特徴的な出力であり、 ユーザーは「なぜこれが絞り込まれているのか」を知りたいからである。

textDocument/completion request
params: {
textDocument: { uri },
position: { line, character },
context: {
triggerKind: 1 | 2 | 3, # Invoked | TriggerCharacter | TriggerForIncompleteCompletions
triggerCharacter?: "." | ":"
}
}
returns: CompletionItem[] | CompletionList | null

サーバーはフラットな配列(incomplete-list挙動なし)を返すか、nullを返す (補完候補が得られない — 空配列とは区別され、空配列は「試したが何も 得られなかった」を意味する)。

{
label: "upcase", # what the user sees
kind: 2, # CompletionItemKind::Method
detail: "() -> String", # signature on the right side
documentation: { kind: "markdown",
value: "..." }, # popup body
insertText: "upcase", # what the editor inserts
filterText: "upcase", # what the client fuzzy-matches against
sortText: "0_upcase" # sort priority (server-side rank)
}

sortTextはサーバーにランクのレバーを与える。v1のランキングは以下の通り:

  1. 所有クラスの近さ — レシーバーの厳密なクラス上のメソッドが、 継承元の祖先のメソッドよりも上位にランクされる。
  2. 可視性 — public > protected > private。
  3. 辞書順 — ランクグループ内のタイブレーク用。

経験的に、これはエディタ利用者の期待と一致する(Stringレシーバー上で 入力しているとき、String#upcaseObject#hashに勝つ)。

パイプライン:

  1. バッファをパースする。Prismのエラー回復が部分的なASTを 出力する。カーソルは、nameが空または部分識別子であるCallNodeの 上か直後にある。
  2. レシーバーを特定する。ASTをカーソル位置のノードへと辿る。 レシーバーはそのcallノードのreceiverである。
  3. レシーバーの型を推論する。hoverプロバイダがすでに使っているのと 同じScope#type_ofパスを使う。
  4. メソッドを列挙する。名前的型については Reflection.instance_definition(class_name)で、Union / Intersectionに ついては各メンバーについて列挙する(intersection: メンバーのメソッドの和集合。 union: 意味論的にはメンバーのメソッドの積集合 — ただしcompletionのUX としては「有効でありうるものすべて」の和集合が欲しい)。
  5. 可視性でフィルタする。レシーバーがselfでない場合、 privateメソッドを落とす。
  6. 各メソッドをCompletionItemに変換する

レシーバー型 → 列挙の行列:

レシーバーキャリア列挙ソース
Nominal[C]Reflection.instance_definition(C).methods
Singleton[C]Reflection.singleton_definition(C).methods
Constant<v>Nominal[class_of(v)]として列挙する
Tuple<...> / HashShape<...>名前的祖先(Array / Hash
Refined[...]基底の名前的型を列挙する
Union[A, B, ...]各メンバーのメソッドの積集合(あらゆるunionケースで確実にディスパッチされるメソッドのみ)
Dynamic[T]TがTopでなければTのメソッドを列挙する。さもなくば何もなし(Dynamic[Top]について有用な補完はない)。

Union / Intersectionの列挙は記録しておく価値のある設計ポイントである。 素朴な「メソッドの和集合」は偽陽性を多く生む(レシーバーがInteger | String のときにInteger#upcaseが表示される)。「メソッドの積集合」は安全な集合を 与える。v1では積集合を採用する。UXフィードバックによって緩和すべきか 判断する。

パイプライン:

  1. パース + カーソル位置のConstantPathNodeを特定する。
  2. レキシカル・ネスティングチェーン経由で親定数を解決する (Reflection.constant_type_forを反映)。
  3. 子定数を列挙する:
    • Reflection.instance_definition(parent_fqn).declarationsから内部クラス /モジュール。
    • Environment::Reflection#known_classes内のネストされた Type::Singleton登録。
  4. それぞれをCompletionItemに変換する。kind: 7(Class)/ kind: 9(Module)/kind: 21(Constant)。

LSP capabilities:

completionProvider: {
triggerCharacters: [".", ":"],
resolveProvider: false # CompletionItem fields are filled at request time
}

なぜresolveProvider: trueにしないのか?completionItem/resolveは、 ユーザーが特定の項目をハイライトするまでdetaildocumentation フィールドの送信を遅延させ、大規模な補完集合での帯域を節約できる。 Rigorの典型的な補完集合(大半のレシーバーで50メソッド未満)では帯域節約は ごくわずかであり、ラウンドトリップが遅延を増やす。v1ではすべてを 先頭で送信し、特に大規模な列挙(BasicObjectの子孫など)が出荷される 段階になればresolveが関連してくる。

トリガー文字が:のときは、直前の文字をMUST確認する — 意味のあるトリガーは ::(定数パス)のみであり、裸の:はシンボルリテラルの開始であって、 v1ではシンボルを自動補完しない。

編集途中のバッファは定義上ill-formedである。Prismのエラー回復は 歩行可能な「ベストエフォート」のASTを生成する。Completionパイプラインは パースエラーを許容し、部分的な情報を使う。

失敗モード:

  • Prismは使えるASTを返すが、呼び出しサイトのレシーバー型が Dynamic[Top](推論で絞り込めなかった) → 空の補完リストを返す (LSPとして正しい「試したが何も得られなかった」)。
  • Prismが部分的なASTすら作れない → レキシカルコンテキスト検出に フォールバックする。カーソル直前の200文字を読み、メソッド補完は /(\S+)\.(\w*)$/、定数パスは/(::?[A-Z]\w*)+(::)?(\w*)$/で マッチさせる。どちらにも一致しなければnilを返す。
  • レシーバーがリテラルのnilNilClassのパブリックメソッド (nil?inspectto_s)のみを返す。

フィルタリング: サーバーサイドかクライアントサイドか?

Section titled “フィルタリング: サーバーサイドかクライアントサイドか?”

LSPクライアント(VSCode、Neovimのnvim-cmp、Emacsのlsp-mode)は すべて、ユーザーの入力プレフィックスに対してCompletionItem[].labelの ファジーフィルタリングを行う。サーバーが厳密なプレフィックス一致で事前 フィルタすることもできるが、そうすると:

  • isIncomplete: trueフラグが強制され、クライアントがキーストロークごとに 再取得することになる。
  • ファジー/部分文字列マッチというエディタのイディオムと合わない。
  • 大して節約にならない。サーバーはすでにすべて列挙しており、N件のラベルの フィルタリングは安価である。

決定: v1はレシーバーの候補集合をフィルタせずまるごと返す。 クライアントがUXに応じてフィルタする。サーバーは可視性フィルタ (selfでないレシーバー上のprivateメソッド)を適用する。これは UXの好みではなく正しさの境界だからである。

各スライスはコミット + specを個別に出荷する。合計8スライス — hoverで4、completionで4。hoverスライスが先に着地する。理由は 小さく、かつcompletionが依存する同じScope#type_ofパイプラインを 行使するからである。

  1. HoverRendererコラボレータ + case-on-nodeディスパッチの足場。 デフォルトボディはslice-5の出力とbit-for-bitで一致させる。 特殊化を1つ着地させる(Prism::CallNode → レシーバー + シグネチャ)。 specはデフォルトとcall分岐の両方をカバーする。
  2. 定数のレンダリングConstantReadNode / ConstantPathNode)。 FQN + シングルトン型 + ソース位置。
  3. ローカル + インスタンス変数のレンダリングLocalVariableReadNode / InstanceVariableReadNode)。型 + bound-atの行。
  4. リテラルレンダリングの磨き上げIntegerNode / StringNode / ArrayNode / HashNode / SymbolNode)。リテラル値 + キャリア + 精製名の表出。
  1. textDocument/completionの登録 + obj.|に対するメソッド補完 (レシーバー型が既知の場合)。新しいCompletionProviderコラボレータ + Serverへの新しいディスパッチ行。capabilityを広告する。specは "x = 'hi'; x.|"というバッファでStringのメソッドが返ることをカバーする。
  2. 定数パス補完 Foo::|について。 Environment::Reflection#known_classesを親FQNの子に絞り込んで列挙する。
  3. Union / Intersection / Refinedレシーバーの扱い。Unionには メソッドの積集合、Refinedには基底の名前的型、shapeキャリアには 祖先の名前的型を用いる。
  4. パース回復 + レキシカルフォールバック。Prismが回復できない バッファについて。ASTが欠落または不完全のとき、カーソル文脈の 正規表現でobj. / Foo::の形状を照合する。
操作目標壁時計時間パス
Hover(slice 1-4)< 100ms p95Scope#type_of + レンダラーディスパッチ。LSP v1のslice-5 hoverと同じホットパスに、よりリッチなmarkdown構築のための約10msを加えたもの。
Completion `obj.`< 150ms p95
Completion `Foo::`< 50ms p95

これらはウォームキャッシュ、ProjectContextのウォームアップ後の状態を 仮定している(LSP v1 slice 7の領域)。コールドスタートのhoverは基底の Environment.for_projectコスト(約3秒)にboundされており、スライス ローカルではない。

  • textDocument/signatureHelp — completionの自然な補完であり、 引数リスト内でのパラメータリストヒント。表面が独立しているためキューイング する。hover + completionはカーソル停止とトリガー文字のケースをカバーするが、 signatureHelpは引数リスト内のケースをカバーし、それ自身のUX + パース回復の問題を持つ。
  • スニペット展開 — 例えばdef foo → 複数行のdef fooボディ テンプレート。LSPはCompletionItem.insertTextFormat = 2(Snippet)で サポートする。UX駆動でキューイング。
  • ハッシュキー補完 HashShapeキャリアについて。概念的にはRigorが 出荷できる最も型駆動な補完だが、hash[:|]のパース回復はそれ自体が 1スライスである。
  • 裸の名前による補完(ローカル + 暗黙のself上のメソッド)。 Object上のすべてのメソッド + スコープ内のすべての定数を表に出すため、 良いランキングのヒューリスティックなしではノイズ対シグナル比が悪い。
  • シンボル補完:|で既知のシンボルの自動補完を発火させる。 シンボルが既知の集合(Hashキー/ActiveRecordスコープなど)から来る ときに有用だが、プラグインの関与を必要とする。
  • 複数オーバーロードのシグネチャ表示 — RBSメソッドが複数の オーバーロードを持つとき、現状のhoverは最初のオーバーロードのみ 表示する。複数オーバーロード表示はmarkdownテーブルのサブ問題である。
  • 使用テレメトリによる補完ランキング — 「ユーザーがto_sを最も 頻繁に選ぶ」。今日テレメトリパイプラインはなく、キューイングする。
  • Unionレシーバーの補完: メソッドの積集合は保守的だが、ユーザーを 驚かせる可能性がある(「レシーバーがInteger | Floatのとき、なぜ Integer#zero?はリストにないのか?Floatにもzero?があるからで — 実際あるので、この例は機能する」)。保守的なデフォルトを選択し、 UXフィードバックが反対であれば改訂する。
  • completionItem/resolveのラウンドトリップ — 遅延か即時か? v1は即時(最初のリクエストで完全ペイロード)。Object系の補完集合が 目立つようになれば再評価する。
  • hoverのメソッド定義ソース位置 — RBS宣言は .rbsファイルを参照する locationを持つ。ユーザー向けhoverには、「lib/foo.rb:12で定義」の 方が「sig/foo.rbs:5で定義」よりも有用である。.rbs宣言から .rb ソースを解決するにはプロジェクト側のマッピングテーブルが必要であり、 スライス計画には入っていないがフォローアップとして記録に値する。
  • プラグイン側の補完コントリビューション — プラグイン (例: rigor-rails-routes)は解析器が知り得ないメソッド名 (signed_id、ヘルパーメソッド)をコントリビュートできる。プラグイン APIの拡張が必要で、具体的なプラグイン需要の後ろにキューイングする。
  • textDocument/hoverrangeフィールド — hover対象ノードの ソース範囲を返し、エディタが単一文字のカーソル位置ではなく正確な式を ハイライトできるようにする。些細な拡張であり、安価であればslice 1で 着地しうる。

スライス1-4(hover)がスライス5-8(completion)より先に出荷されるのは、 以下の理由による:

  • Hoverスライスはより小さく、completionが依存する同じScope#type_of + ノード位置特定パイプラインを行使する。
  • よりリッチなmarkdownレンダリング作業(メソッドシグネチャ、ソース位置、 精製名の表出)は、hoverとcompletionのCompletionItem.documentationの 間で再利用可能である。
  • HoverのミスステップはLSPセッションを壊さないが、completionの ミスステップ(パース回復、壊れた構文上でのAST歩行)は壊しうる。

スライス5はcompletionのMVP(メソッド補完のみ)を着地させる。 6-8で定数パス、union / shapeレシーバー、パース回復へと拡張する。 各スライスは独立してリバート可能である。

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