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

前回

tennashi.hatenablog.com

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

前回のまとめ

gopls で採用されている cache は 3 層で前回読んだのは Cache/Session の 2 層だ。

  • Cache
    • 素朴な OS ファイルシステム上のファイルの cache
    • cache 自体の実体は source.FileHandle 型(の実装)
  • Session
    • LSP の Text Document Synchronization でやりとりされる TextDocument の cache
    • cache 自体の実体は source.Overlay 型(の実装)

Session に cache が登録されるときに、その TextDocument の URI から OS ファイルシステム上のファイルを Cache に cache することを注意しておく。

Session の定義は以下だ。

  • source.Session interface
type Session interface {
    NewView(ctx context.Context, name string, folder span.URI, options Options) (View, Snapshot, func(), error)
    View(name string) View
    ViewOf(uri span.URI) (View, error)
    Views() []View
    Shutdown(ctx context.Context)
    GetFile(ctx context.Context, uri span.URI) (FileHandle, error)
    DidModifyFiles(ctx context.Context, changes []FileModification) ([]Snapshot, []func(), []span.URI, error)
    Overlays() []Overlay
    Options() Options
    SetOptions(Options)
}
  • cache.Session
type Session struct {
    cache *Cache
    id    string

    options source.Options

    viewMu  sync.Mutex
    views   []*View
    viewMap map[span.URI]*View

    overlayMu sync.Mutex
    overlays  map[span.URI]*overlay

    gocmdRunner *gocommand.Runner
}

今回はこの Session からさらに生成される View について読む。

View

View は設定と設定された package を持つものだ。
cf. https://github.com/golang/tools/blob/master/gopls/doc/implementation.md#viewsessioncache

いよいよよく分からない、のでさっさとコードを読む。

type View interface {
    Session() Session
    Name() string
    Folder() span.URI
    ModFile() span.URI
    BackgroundContext() context.Context
    Shutdown(ctx context.Context)
    AwaitInitialized(ctx context.Context)
    WriteEnv(ctx context.Context, w io.Writer) error
    RunProcessEnvFunc(ctx context.Context, fn func(*imports.Options) error) error
    Options() Options
    SetOptions(context.Context, Options) (View, error)
    Snapshot(ctx context.Context) (Snapshot, func())
    Rebuild(ctx context.Context) (Snapshot, func(), error)
    ValidBuildConfiguration() bool
    IsGoPrivatePath(path string) bool
    IgnoredFile(uri span.URI) bool
}
type View struct {
    session *Session
    id      string

    optionsMu sync.Mutex
    options   source.Options

    mu sync.Mutex

    baseCtx context.Context

    backgroundCtx context.Context

    cancel context.CancelFunc

    name string

    folder span.URI

    root span.URI

    importsMu sync.Mutex

    processEnv              *imports.ProcessEnv
    cleanupProcessEnv       func()
    cacheRefreshDuration    time.Duration
    cacheRefreshTimer       *time.Timer
    cachedModFileIdentifier string
    cachedBuildFlags        []string

    filesByURI  map[span.URI]*fileBase
    filesByBase map[string][]*fileBase

    snapshotMu sync.Mutex
    snapshot   *snapshot

    initialized            chan struct{}
    initCancelFirstAttempt context.CancelFunc

    initializationSema chan struct{}
    initializeOnce     *sync.Once
    initializedErr     error

    hasValidBuildConfiguration bool

    modURI, sumURI span.URI

    tmpMod bool

    hasGopackagesDriver bool

    gocache, gomodcache, gopath, goprivate string

    goEnv map[string]string
}

コメントは省略したが、session.View interface には以下のようなコメントがある。
// 原文はリンク先を参照してほしい、ここでは訳を書いておく。

// View は単一のワークスペースを表現する。
// これは、ワーキングディレクトリやビルドタグのような設定値を保つためのレベルだ。

View の生成

これを確かめるために View の生成がどこで行われているかを追っていく。

View の生成は (Session).NewView() メソッドにより行われる。
このメソッドは (*Server).addView() からのみ呼ばれており、さらに addView()(*Server).addFolders() からのみ呼ばれる
この addFolders() メソッドは以下の3ヶ所で呼ばれている。

それぞれ以下の LSP メソッドと対応している。

とにかく、クライアントが何か TextDocument を開いたときに叩かれるものだと思えばよい。

gopls が initialize ハンドラでなく initialized ハンドラで addFolders() を呼んでいるのは初期化フェイズが完全に完了したことを gopls が確認するためである。
initialize() ハンドラでは pendingFolders というフィールドに一時的に溜めておき、initialized() ハンドラで実際に View を作成する。

didOpen() から始めて View の生成を追っていく。

didOpen() では通知された URI を含むディレクトリごと addFolders() に渡している。
cf. https://github.com/golang/tools/blob/c1903db4dbfe5ab8e2ec704e203535dae53c2adc/internal/lsp/text_synchronization.go#L101-L106

addFolders() ではディレクトリ毎に addView() を呼んでいる。
cf. https://github.com/golang/tools/blob/d1954cc86c824f62dd7845fadb8b09b089425026/internal/lsp/general.go#L202

で、addFolders() は最終的に NewView() を呼び、そのディレクトリで View を生成する。
cf. https://github.com/golang/tools/blob/c1903db4dbfe5ab8e2ec704e203535dae53c2adc/internal/lsp/workspace.go#L41

結果として View がディレクトリ単位で生成されることが分かる。

ワークスペース、とは

ここまで濁してきたのだが、ワークスペースとはなんだろうか。
例えば、initialize リクエストで送信される、InitializeParams に書かれた以下のコメントを読む。

   /**
    * ワークスペースの rootUri。開いているフォルダが無い場合
         * は null。`rootPath` と `rootUri` が両方設定されている場合
         * は `rootUri` が勝つ。
    */

ワークスペースとは特定のディレクトリのことで、その root が初期化時に渡されるのだろうと分かる。

一方、LSP 仕様にはワークスペースフォルダという言葉も出現しており、例えば以下のようなところで見られる。

https://microsoft.github.io/language-server-protocol/specification#workspace_workspaceFolders

全体の訳は書かないが、適当にかいつまむと、

  • さまざまなツールがワークスペース毎に複数のルートフォルダをサポートする
    • 例えば VS Code の multi-root support、Atom の project folder、Sublime の project
  • このような機能を持つエディタが複数のワークスペースフォルダを開くときには InitializeParamworkspaceFolders に入れてくれ

細かいニュアンスは原文を参照してもらいたいが、ワークスペースのルートフォルダ == ワークスペースフォルダと読める。

先の workspace/didChangeWorkspaceFolders はこれを追加、削除するための通知だ。

LSP 仕様的にはワークスペースフォルダとはただのディレクトリではなくプロジェクトルートのような意味合いで書いているように読める。

では、Go での対応する概念はなんだろうか。

先に述べたとおり session.View は単一のワークスペースを表わす。
cf. https://github.com/golang/tools/blob/master/internal/lsp/source/view.go#L122

その実装である cache.View には root というフィールドがあり、ここには GOPATH mode ならただのディレクトリで、module-aware mode なら module root が入る。
cf. https://github.com/golang/tools/blob/39188db5885871ea0132a6314eab0c1b5d179a8b/internal/lsp/cache/view.go#L63-L65

つまり module(module-aware mode なら) または package(GOPATH mode なら) 単位であると思えばよさそうだ。

ただし、View 自体はディレクトリ毎に生成される点はここまで読んだ通りだ。

ここまでで、View が単一ディレクトリの何かしらの意味での cache であることまでが分かった。

長くなってしまったので今回はここまで。

次回は View がどのような意味での cache であるのかを明らかにしたい。