LSP 実装メモ(gopls cache `View` 生成詳解編)
前回
引き続き、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 なファイルシステムをどうにか扱うために以下のコミットで追加された処理だ。
ファイルシステム上は Hoge/Fuga.go
という名前で保存されたファイルをエディタが hoge/fuga.go
などのファイル名で送信してきたとき、gopls では扱えなくなるので、エラーにして叩き落とすという処理をしている。
macOS(darwin) と Windows のみ意味のある処理をしており、それ以外の OS では何もせず nil
を返す。
これはファイルシステムの問題であり、OS の問題ではないので、Linux で case-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
を埋めるかどうかを決める。
ExpandWorkspaceToModule
は gopls
が現在開こうとしているディレクトリ(== ワークスペース)からそれを含むモジュール全体へとスコープを拡張するための設定値だ。
これを true
にすることで現在開こうとしているディレクトリを含む module からさらにディレクトリ探索をして go.mod ファイルの位置なども cache するように動作する。
monorepo で特に便利になる。
cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L334-L337
TempModfile
が false
か 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()
関数の処理を自前で用意した実行ファイルに置き替えるための変数である。
詳細は以下を読むとよい。
(*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()
シグネチャは以下。
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) -- かいた