GoのLSPで補完が機能しなくなったので調べた
TL;DR
Windowsなどの大文字小文字の違いを無視するファイルシステムにおいては、goプログラムを置いた場所を環境変数PATHに設定する際に、大文字小文字を正しく設定しましょう。大文字小文字が間違った状態のままgopls(LSPサーバー)を使うと、ランタイムのファイルに関する操作ができなくなる場合があります。
またこの状態でもGOROOTに大文字小文字の正しいパスを設定することで、goplsは正しく機能するようになります。
現象を確認したバージョン
- Windows 11
- Go 1.21.1 windows/amd64
- gopls v0.13.2 (golang.org/x/tools/gopls@v0.13.2)
発見に至る過程
ある日の風呂上り、湯冷めを待つ間に小物のプログラムでも試作してみよう、とGoを書き始めました。Goは自分にとって使い慣れたプログラミング言語ではありますが、何事も使う時にソースを読むなど調べる癖の付いてる私は、Vimからvim-lspのシンボルの定義元を参照するコマンドtextDocument/definitionを用いてランタイムパッケージのソースを見つつ書き進めていました。そうやって開いたランタイムのソースファイル内から、更にtextDocument/definitionで別のランタイムパッケージのソースを飛ぼうとして失敗し、この問題に気が付きました。

切り分け
これらの機能は以前は動いてました。ならば間接的には、最近のGo及びgoplsの更新で動かなくなったのであろうと推測できます。他にも私のGoのインストール環境が特殊であることも一因であると推測されます。
まずは後者の特殊なインストール環境からと切り分けを開始しました。何が特殊かと言いますと、D:\Go以下に複数のバージョンのGoをインストールし、シンボリックリンクの張替えで複数のバージョンを手軽に切り替えて使えるようにしています。以下の画面の例ではシンボリックリンクcurrentをgo1.21.1.windows-amd64に張った状態を示しています。この状態でD:\Go\current\binへ環境変数PATHを通しています。ここからリンクの向き先を変えればPATHをいじることなくGoのバージョンを切り替えられるというわけです。

当初はこのようにシンボリックリンクを経由していることが原因だと推測しました。それを検証するためにPATHの設定を変え、直接D:\Go\go1.21.1.windows-amd64\binを参照するようにしました。すると問題は発生しなくなりました。そのためシンボリックリンクの介在の有無が原因なのだなと、結果的に誤った推測をしたのです。
しかし下図のgo env GOROOTの実行結果を眺めていて、ふと気が付きました。これgoがどうして小文字になってるんだ? 実際のパスはD:\Go\currentなのですが、実行結果はD:\go\currentだったのです。とはいえファイルパスの大文字小文字を無視するWindowsですから、両者はまったく同じパスとなり問題はないはずです。問題があるとすれば…GoのGOROOTを決定するアルゴリズムにパスを小文字にしてしまう問題と、goplsのソースファイルがランタイムパッケージか判定するアルゴリズムに大文字小文字を区別してしまう問題、その両方が噛み合った結果としてこの問題が発生している可能性を思いつきました。

というわけでその可能性を検証するために、手っ取り早く環境変数GOROOTへ明示的にD:\Go\currentを指定したところ、見事に問題は解消しました。ランタイムパッケージ内のファイルからシンボルの定義元へジャンプできるようになったのです。
推測される原因
これまでから推測される2つの原因を列挙しましょう。
- Goでは環境変数GOROOTを省略するとGOROOTを推定する。その推定時にWindowsではファイルパスの大文字を小文字にしてしまうことがある
- goplsではGOROOTに依存してランタイムパッケージ内のファイルを扱う構造を持っている。そのため大文字小文字の違いの影響を受ける
この2つの合わせ技により問題が発生していたと考えられます。
原因調査
さてココで終っては面白くありません。プログラムの問題というのは、それと対応関係にあるソースコードがあるものです。ということでソースコードを調べてみました。
GoのGOROOTはどのように決まるのか
Go製のプログラムからGOROOTにアクセスするにはruntime.GOROOT()を使います。
runtime.GOROOT()では環境変数GOROOTが存在するならばその値を返します。それが存在しない場合にはdefaultGOROOTという、ビルド時にリンカーによって埋め込まれる変数を参照しています。環境変数GOROOTが存在しない状態で今回の問題が発生しているので、変数defaultGOROOTを参照していると考えられます。
ではdefaultGOROOTはどのように決まっているのでしょうか。それはリンカーにとってのruntime.GOROOT()です。つまり環境変数GOROOTが存在しない場合は、リンカーのバイナリをビルドした際のリンカーによって埋め込まれた値が流用されます。お、なんかややこしくなってきましたね。
ところでGoにおいてはリンカーはgoプログラムによって起動される子プロセスです。つまりgoプログラムが環境変数GOROOTを設定した状態でリンカーを起動した場合には、埋め込まれるdefaultGOROOTはgoプログラムによって設定されたものになります。で、実際にそのようになっていました。
goプログラムは、自身の絶対パスの1つ上もしくは2つ上がGOROOTである可能性を調べ、そうならばGOROOTにそこを設定します。具体的に言うと D:\Go\current\binにあるgo.exeは、D:\Go\currentもしくはD:\GoがGOROOTである可能性を考慮して、正しくD:\Go\currentがGOROOTであるように設定するのです。これによりGoで書かれビルドされた実行ファイルのdefaultGOROOTにはビルド時のGOROOTの値が設定されていることになります。
goプログラムは自身の絶対パスを調べるのにos.Executable()を使います。私の環境ではこの時点で D:\go\current\bin\go.exe が返ってきていました。ここである可能性に思い当たります。「環境変数PATHに小文字でD:\go\current\binと書かれてるのでは?」 ハイ、そのとおり。環境変数PATHの該当エントリをD:\Go\current\binに書き換えたところ、os.Executable()がD:\Go\current\bin\go.exeを返すようになりました。
GoがどのようにデフォルトのGOROOTの値を決めるのかという謎は解けました。goプログラムのために通した環境変数PATHの値に依存して、デフォルトのGOROOTの値が決定していたのです。
goplsはどのようにGOROOTの影響を受けるのか
goplsの調査にあたって、まずどのようなエラーが発生しているかを確認しました。結果no package metadata for file file:///D:/Go/current/src/os/env.goとのことで、文字通りos/env.goのパッケージに対するメタデータが見つからないとのこと。該当するメッセージを生成しているソースコード2箇所(その1, その2)の周辺をみると、ファイルのURIに対してそのファイルが所属しているパッケージのgoplsが管理しているメタデータが見つからないということがわかります。
実際に入ってないのか?と確かめたところ、file:///D:/Go/current/src/os/env.goは入っていませんでしたが、file:///D:/go/current/src/os/env.goは入ってました。やはりGOROOTの影響を受けています。となると、この値がどこから来てるのかが問題となります。
これは"golang.org/x/tools/go/packages".Load()(以下packages.Load())から来てました。このpackages.Load()は内部でgo listを実行してパッケージについての情報を取得します。ここで特に注目するのはパッケージのある物理的なディレクトリのパス(.Dir)です。ランタイムパッケージについてはこれがGOROOTの影響を受けます。以下のコマンドは、それを確認できる実行例です。GOROOTの値に応じてランタイムパッケージosの物理パスが変わっているのがわかります。
$ GOROOT='D:\Go\current' go list -f '{{.Dir}}' os
D:\Go\current\src\os
$ GOROOT='D:\go\current' go list -f '{{.Dir}}' os
D:\go\current\src\os
packages.Load()はこうして得たgo listの.Dirと.GoFilesから、パッケージを構成する各ファイルのフルパスを合成します(参考コード)。goplsはパッケージごとに作成したメタデータを、そのファイルのフルパスをURIへ変換し(参考コード)、URIをキーとしてパッケージのメタデータを参照できるように保存します(参考コード)。
ここまででGOROOTの値に応じて、goplsの内部のデータベースに登録されるキーが変わってしまい正しく参照できなくなることがわかりました。次に気になるのはgoplsの中でのGOROOTの伝搬方法です。特にクライアント・サーバモードでのgopls間での伝搬と、goplsからpackages.Load()への伝搬に注目しましょう。
通常Vimからvim-lspなどを通じてgoplsを利用する時は、ストリームモードで子プロセスとして利用します。この時エディタ側で設定されている環境変数GOROOTはそのままgoplsへ引き継がれます。逆に環境変数GOROOTを設定してない時は、前節で見たようにgoplsにおけるGOROOTの値はgoplsをビルドした際の環境変数PATHの値に応じて決まります。
一方でgoplsにはプロセスを2つに分け、クライアント・サーバーモデルで動作させるモードがあります。この時でもVim(goplsクライアント)での環境変数GOROOTの設定がgoplsサーバーに伝わります。それを伝えているのがinitializeリクエストでした。クライアント側はgo envを用いてGOROOT他の値を取得し、それをinitializeリクエストに添付します(参考コード1, 参考コード2)。サーバー側はinitializeリクエストのハンドラーでinitializationOptionsからGOROOT他の値を抽出し、セッションのオプションとして記録します(参考コード)。
goplsのセッションのオプションとして記録されたGOROOTは、pacakges.Load()の呼び出しの際の第1引数のConfigに引き渡されます(参考コード)。そのConfigがgo listの実行時の環境変数として利用されます(参考コード)。
こうして見てきたようにgoplsにおいては、GOROOTの値はLSPプロトコル他を通じて引き渡され、pacakges.Load()にまで到達してその出力のファイルパスに影響を与え、さらにデータベースのキー相当のURIに変換して利用されていました。URIは大文字小文字を区別するのに対し、Windowsのファイルパスは大文字小文字を区別しませんから、ファイルパスをURIへ変換する際に正規化しておくのが正着であると、推察できます。
まとめ
GOROOTのデフォルト値はリンカーによって埋め込まれます。その値はビルド時のgoプログラムのフルパスから決定されます。goプログラムのフルパスは環境変数PATHの影響を受けます。
goplsは情報を収集するのに内部的にgo listコマンドを利用しています。その際に取得できる値、ファイルパスはGOROOTの影響を受けます。goplsはそのファイルパスをURIに変換して、そのURIを各種情報のキーに利用します。Windowsがファイルパスの大文字小文字を区別しないのに対し、URIは常に大文字小文字を区別するという違いを、その変換の時に考慮していないために、goplsにはGOROOTの値によっては正常に動作できないケースが存在します。
最後に、goプログラムのために環境変数PATHに設定する値は、大文字小文字を正確に記述しましょう。
参考資料
調査の際に残したメモが以下のURLにあります。興味のある人は参照してみてください。
Discussion