LSP 実装メモ(gopls cache `View` 生成詳解編)

前回

tennashi.hatenablog.com

引き続き、gopls の cache 実装を読む。

前回のまとめ

View 実装のフィールドとその生成タイミングを見ることで、ディレクトリ単位で何かしらの cache をしていることが分かった。

View の初期化処理をもう少し詳しく追っていく。

View は Session の NewView() メソッドにより生成される。
ここからコールスタックは以下のように積まれる。

  • (*Session).NewView()
    • (*Session).createView()
      • (*View).setBuildInformation()
        • (*View).goVersion()
        • (*View).setGoEnv()
      • (*View).findAndBuildWorkspaceModule()
        • (*snapshot).buildWorkspaceModule()
      • (*View).setBuildConfiguration()
      • (*View).findWorkspaceDirectories()
      • (*View).initialize()
        • (*snapshot).load()

今回はこれらのメソッドの中でやっていることを追う。

(*Session).NewView()

シグネチャは以下。

func (s *Session) NewView(ctx context.Context, name string, folder span.URI, options source.Options) (source.View, source.Snapshot, func(), error)

これは単純なエントリポイントで、メインの実装は次の createView() だ。
ここではロックを取って、createView() が生成したものを View は Session に保持しつつそのまま返す。

(*Session).createView()

シグネチャは以下。

func (s *Session) createView(ctx context.Context, name string, folder span.URI, options source.Options, snapshotID uint64) (*View, *snapshot, func(), error)

引数に snapshotID が追加されており、NewView() からは 0 と指定される。
また source.View source.Snapshot の実装となっている *cache.View *cache.snapshot 型が返り値になっている点が NewView() との違いである。

cache.View 型と cache.session 型のフィールドの初期化して返すのがここでの仕事である。
初期化するためにいくつかの処理が必要なものは別メソッドとして分離されており、それらは以下で説明するものである。

(*View).setBuildInformation()

シグネチャは以下。

func (v *View) setBuildInformation(ctx context.Context, options source.Options) error

options にはユーザが指定した gopls の設定が入る。

まず最初に checkPathCase() という関数を呼んでいる。
これは、case-insensitiveファイルシステムをどうにか扱うために以下のコミットで追加された処理だ。

github.com

ファイルシステム上は Hoge/Fuga.go という名前で保存されたファイルをエディタが hoge/fuga.go などのファイル名で送信してきたとき、gopls では扱えなくなるので、エラーにして叩き落とすという処理をしている。
macOS(darwin) と Windows のみ意味のある処理をしており、それ以外の OS では何もせず nil を返す。
これはファイルシステムの問題であり、OS の問題ではないので、Linuxcase-insensitiveファイルシステムを利用する場合は...まぁ下手に大文字ファイル名を使わなければハマらない...多分。

その後 goVersion() で Go コマンドのバージョンを埋める。

さらに setGoEnv()GO*環境変数の設定をする。
この返り値は $GOMOD になっており、$GOMOD は module-aware mode において、main module が見付からないと /dev/null を返す仕様になっている。
cf. go help environment

もし $GOMOD/dev/null ならこの時点で処理は終了する。
なお注意すべきは GOPATH mode なら、$GOMOD == "" なので処理が続くということだ。

次に (View).modURI(View).sumURI を埋め、ExpandWorkspaceToModule という設定値に応じて v.root を埋めるかどうかを決める。
ExpandWorkspaceToModulegopls が現在開こうとしているディレクトリ(== ワークスペース)からそれを含むモジュール全体へとスコープを拡張するための設定値だ。
これを true にすることで現在開こうとしているディレクトリを含む module からさらにディレクトリ探索をして go.mod ファイルの位置なども cache するように動作する。
monorepo で特に便利になる。 cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L334-L337

TempModfilefalse か GOPATH mode の場合はここで createView() に戻る。

TempModfile は Go 1.14 から追加された、-modfile オプションを gopls が解釈するかどうかを指定する設定値だ。
cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L319-L320

その後に到達するということは TempModfile == true かつ v.modURI != "" なので、v.workspaceMode を設定して終了である。

(*View).goVersion()

シグネチャは以下。

func (v *View) goVersion(ctx context.Context, env []string) (int, error)

GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'v.root で実行すると例えば以下のように Go のバージョンリストが手に入る。

$ GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'
[go1.1 go1.2 go1.3 go1.4 go1.5 go1.6 go1.7 go1.8 go1.9 go1.10 go1.11 go1.12 go1.13 go1.14]

これをパースして、Go 1.14 であれば 14 を取得する。

(*View).setGoEnv()

シグネチャは以下。

func (v *View) setGoEnv(ctx context.Context, configEnv []string) (string, error)

ここでは go env -json GO111MODULE GOFLAGS GOINSECURE GOMOD GOMODCACHE GONOPROXY GONOSUMDB GOPATH GOPROXY GOROOT GOSUMDB を実行して JSON 形式で必要な環境変数を取得している。
実行時の環境変数として configEnv の値を入れることで環境変数の上書きを実現している。

それらの環境変数v.goEnv に入れられ、その中でも GOCACHE GOPATH GOPRIVATE GOMODCACHE の中身は v.go.* という変数にも投入される。
さらに GOMODCACHE は Go 1.15 で追加されたもので、それ以前のバージョンは GOMODCACHE=$GOPATH/pkg/mod として解釈している。

さらに GOPACKAGESDRIVER という環境変数の処理が続く。
これは x/tools/go/packages パッケージで使われるもので、go env では返ってこないので自前でやっている。
この環境変数packages.Load() 関数の処理を自前で用意した実行ファイルに置き替えるための変数である。
詳細は以下を読むとよい。

github.com

(*View).findAndBuildWorkspaceModule()

では setBuildInformation() とそこから呼ばれているメソッドを解説したので、createView() に戻って、次のメソッドを解説する。

シグネチャは以下。

unc (v *View) findAndBuildWorkspaceModule(ctx context.Context, options source.Options) error

このメソッドは v.root から順に go.mod を探していき、見付けたらそれを v.modules に追加していく。

この処理をするためには ExpandWorkspaceToModule オプションと ExperimentalWorkspaceModule オプションの両方が有効になっている必要がある。

この処理をするときには v.workspaceMode には workspaceModule フラグが立てられる。

探索では go コマンドが無視するディレクトリは無視される。
具体的には . _ から始まるディレクトリ名と testdata ディレクトリ、/vendor 配下のディレクトリだ。

探索が終わると (*snapshot).buildWorkspaceModule() を呼ぶ。

(*snapshot).buildWorkspaceModule()

シグネチャは以下。

func (s *snapshot) buildWorkspaceModule(ctx context.Context) (*modfile.File, error)

返り値の *modfile.File は go.mod ファイルそのものである。
cf. https://godoc.org/golang.org/x/mod/modfile#File

ここでやっていることは現在関連付けられている v.modules 全てを依存として持つ大きな(仮想) module (workspace module と呼ぶ)を作成することである。

例えば以下のようなディレクトリ構成があるとする。

main/
  - go.mod # module main
  - sub_a/
    - go.mod # module sub_a
  - sub_b/
    - go.mod # module sub_b

このときに以下のような(仮想) go.mod が作成される。

module gopls-workspace

require (
  main v0.0.0-00010101000000-000000000000
  sub_a v0.0.0-00010101000000-000000000000
  sub_b v0.0.0-00010101000000-000000000000
)

replace (
  main => ./main
  sub_a => ./main/sub_a
  sub_b => ./main/sub_b
)

上記の例では一度目の for ループで完結するが、main sub_a sub_b いずれかでさらに replace 文があることを想定して、それを全て反映するための二度目の for ループが存在する。

この生成結果は v.workspaceModule に保持される。

(*View).setBuildConfiguration()

ではまた createView() から呼ばれているメソッドに戻る。

この処理はあまり名前と処理が合っていないように思うが、やっていることは、ここまでの設定値が "正しい" ことを検証している。

  • GOPACKAGESDRIVER が指定されている場合は問答無用で正しい
  • go.mod が見付かっている場合や v.modules を複数見つけている場合は正しい(module-aware mode)
  • それ以外は GOPATH mode のはずで、このときは v.folder$GOPATH/src 配下に存在すれば正しい

というチェックをして、全て満たさない場合は false が返る。
また同時に v.hasValidBuildConfiguration にもその結果が保持される。

(*snapshot).findWorkspaceDirectories()`

また createView() に戻る。
v.modURI が空でない場合は s.GetFile() を呼び cache に go.mod の内容を保持しておく。
その後この findWorkspaceDirectories() が呼ばれる。

シグネチャは以下

func (s *snapshot) findWorkspaceDirectories(ctx context.Context, modFH source.FileHandle) map[span.URI]struct{}

GOPATH mode なら、その s.view.root (つまりそのディレクトリ自体)だけが返される。

module-aware mode のとき、go.mod の replace 文に記載されたディスク上のディレクトリのみ Set(map[span.URI]struct{}) に追加されて返される。

コメントに書いてあるが、GOPATH mode のときは $GOPATH/src` に含まれるディレクトリ全てを見るのが本来やるべき処理だが、too expensive なのでやらないとのことだ。

この返り値は v.snapshot.workspaceDirectories に保持される。

(*View).initialize()

https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/cache/view.go#L691-L744

シグネチャは以下。

func (v *View) initialize(ctx context.Context, s *snapshot, firstAttempt bool)

これは s.load() が実体であり、それを goroutine safe に呼ぶことが仕事である。

ただあまりよく分かってないのが、semaphore と sync.Once を併用しているところだ。
最初に呼ばれた一回、という制約をかけるためだろうか...?

その後は引数を準備して、s.load() を呼び chan を閉じれば完了だ。

なお、このように初期化処理は並行に行なわれるため、View には AwaitInitialized() というメソッドが用意されており、初期化の完了を待つことができる。

(*session).load()

...つかれたのでここまで...

次回は (*session).load() から

-- 追記(2020/09/28) -- かいた

tennashi.hatenablog.com