Dumb Jump は、名前がどの宣言を指すかを言語の意味規則から解決せず、宣言らしい文字列を言語別の正規表現で検索する。 この割り切りによって、言語サーバーや索引を常駐させず、同じ検索機構を多数の言語に使い回せる。
xref は利用者側の境界にすぎない
xref は、コード中の名前から定義や参照箇所へ移動するための Emacs 標準インターフェースである。 利用者には定義へ移動する M-.、参照箇所を探す M-?、移動前へ戻る M-, などの共通操作を提供し、実際の検索は交換可能なバックエンドへ委譲する。 したがって xref 自体は言語の構文や意味を解析せず、検索要求、候補表示、ファイルへの移動、移動履歴を仲介する。
Dumb Jump は、この xref に接続するバックエンドの一つである。 xref-backend-identifier-at-point がカーソル位置のシンボルと現在行における左右の文字列を取得し、xref-backend-definitions が検索処理を呼び出す。 検索結果は最後に xref-make-file-location へ変換されるため、候補表示やジャンプ履歴は xref に任せられる。 Dumb Jump が正規表現による検索を担当し、xref が Emacs の標準操作へ接続するという分担である。
言語差分は四つの規則表に置かれる
複数言語への対応は、言語ごとの処理系を起動する方式ではなく、差分を Emacs Lisp のプロパティリストとして宣言する方式で実現される。
dumb-jump-language-file-exts は、言語名、拡張子、ag のファイル種別、rg のファイル種別を対応づける。 言語判定は major mode、ファイル拡張子、major mode 名から mode という接尾辞を除いた名前の順に試される。 同じ拡張子を複数言語が使う場合があるため、major mode を先に見ることで曖昧さを減らしている。
dumb-jump-find-rules は、言語名、定義種別、利用可能な検索器、正規表現、正例、反例を一件の規則として持つ。 正例を tests、反例を not に同居させ、同じ規則表を実行時の検索と規則自体の試験に使っている。 supports が検索器ごとの正規表現能力の差を明示するため、使えない検索器の規則は選択時に除外される。
(:language "go" :type "function"
:supports ("ag" "grep" "rg" "git-grep")
:regex "func\\s+JJJ\\s*\\\("
:tests ("func test(url string) (string, error)"))
Go の規則は関数、メソッド、構造体、インターフェース、型別名、通常代入、短縮変数宣言をそれぞれ定義候補として扱う。 ここで JJJ は後から検索対象の名前へ置換する目印であり、構文木上のノードを表してはいない。
dumb-jump-language-contexts は、カーソル位置の名前より左と右にある文字列だけから、対象が関数、変数、型などのどれらしいかを推測する。 たとえば JavaScript と TypeScript では、名前の直後が開き括弧なら関数規則を優先する。 文脈を一意に判定できない場合は種別を限定せず、その言語で利用できる全規則を検索する。
dumb-jump-language-comments は、言語ごとの行コメント開始文字を持つ。 検索後に行頭の空白を除いてコメント開始文字と照合し、コメント内に書かれた宣言例を候補から落とす。 文字列リテラルやブロックコメントを構文として認識する処理ではないため、ここにもテキスト検索としての限界が残る。
検索処理は生成器と解析器に分かれる
プロジェクトルートは .git、.projectile、Cargo.toml、Makefile などの印を親方向へ探して決める。 .dumbjump があれば検索対象の追加、除外、言語の上書きを読み取り、設定がなければ project.el や既定のディレクトリへフォールバックする。
検索器は、コマンド生成関数、出力解析関数、導入確認関数をまとめたプロパティリストとして抽象化される。 利用者が固定しなければ ag、rg、GNU grep、grep の順で利用可能なものを選ぶ。 選んだ検索器が対象言語の規則を扱えない場合は、利用可能な別の検索器も探す。
規則の正規表現は検索器に依存しない小さな中間表現になっている。 dumb-jump-populate-regex は JJJ を検索対象へ、独自の単語境界であるバックスラッシュ j を検索器固有の境界へ置換し、必要ならバックスラッシュ s を POSIX の空白文字クラスへ変換する。 対象名には regexp-quote を通すため、名前に正規表現の特殊文字が含まれても検索式として解釈されにくい。
コマンド生成関数は、変換済みの複数規則を選択肢として連結し、対象言語のファイル種別と除外パスを加える。 rg の場合は行番号、複数行検索、PCRE2、ファイル種別を指定し、プロジェクトルートに対して一回の全文検索を実行する。 検索結果が空なら、設定により定義らしい形を諦めて対象名の単語境界検索へフォールバックする。
検索器ごとの差は、コマンド生成関数と出力解析関数の組で吸収する。 ag、rg、git grep、grep の出力はいずれも一行ずつファイル、行番号、行の内容へ分解され、現在行との差を diff として加えた共通のプロパティリストになる。 現在の検索元そのものは候補から除外される。
(:path "pkg/user.go"
:line 42
:context "func FindUser(id string) ..."
:diff 18)
順位付けは意味ではなく局所性を使う
候補は重複を除いた後、コメント行を取り除き、現在ファイルの候補を別ファイルより前へ移す。 現在ファイルではカーソルより前にある候補を先に置き、別ファイルではパス文字列の短いもの、行番号の小さいものの順に並べる。 この処理はインポートやスコープを見ていないため、「近くて短いパスにある同名の宣言は選ばれやすい」という局所性を意味解決の代用品にしている。
通常設定では候補が一件だけなら自動でジャンプし、複数残れば xref にすべて返して人間に選ばせる。 旧来の独自画面では dumb-jump-aggressive を有効にすると、文脈と現在ファイルの優先順位から一件を自動選択できる。
参照検索は定義検索の逆算で作られる
参照検索では、最初から宣言構文へ絞らず、対象名を単語境界付きで広く検索する。 得られた各行に対して言語別の定義規則を Emacs の正規表現へ変換して適用し、定義に見える行を除いた残りを参照候補とする。 定義規則の再利用によって各言語に参照規則を追加せずに済むが、同じ行に定義と利用が共存する場合や、文字列およびコメント中の名前まで正しく分類できるわけではない。
新しい言語への対応費用
新しい言語を足す最小単位は、拡張子表への一項目と、主要な宣言形ごとの検索規則である。 必要に応じて文脈規則とコメント開始文字も追加する。 各検索規則に正例と反例を持たせ、grep、ag、rg ごとの試験関数で実際の検索式を検証できるため、規則追加を単なる目視確認だけに依存させていない。
したがって「60 を超える言語に対応する」は、「各言語の意味を理解する」と同義ではない。 同名シンボルのスコープ、インポート、継承、オーバーロード、生成コード、条件付きコンパイルなどを正確には解決できず、検索式に似た文字列を誤検出することもある。 一方、検索規則一件は正規表現と正例および反例で追加できるため、対応言語を増やす費用が言語サーバーの実装より小さい。 精度を型や構文木ではなく、候補の絞り込みと人間による最終選択で補う設計である。