LSP 実装メモ(gopls cache `Cache` `Session` 編)

前回

tennashi.hatenablog.com

今回から少し gopls の cache 機構を集中して読んでいく。
というのも結局 LSP サーバの実装の肝は

  • クライアントから受け取った TextDocument の中身をいつパースするか
  • どのようにパースするか
  • どのように保持するか
  • どうやって TextDocument の中身にアクセスするか

なはずで、gopls の場合この cache 機構を読み解くことがこの大部分の理解に繋がると思われるからである。

cache 機構に関連して参考になるドキュメントは以下である。

github.com

gopls の cache は 3層になっており、それぞれ

  • Cache
  • Session
  • View

と名付けられており、各々が互いに参照及び構築できる。

各々について、もう少し詳細を見ていく。

Cache

cache の最下層にある構造。
ファイルシステムやそのコンテンツの情報などのグローバルな情報を保持する、らしいがよく分からないので、gopls 内の実装も読んでみる。

型定義

Cache 型は以下のように定義されている。

type Cache struct {
    id      string
    fset    *token.FileSet
    options func(*source.Options)

    store memoize.Store

    fileMu      sync.Mutex
    fileContent map[span.URI]*fileHandle
}

メソッド

また、この型の公開されたメソッドは以下である。

func (c *Cache) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error)
func (c *Cache) NewSession(ctx context.Context) *Session
func (c *Cache) FileSet() *token.FileSet

他にも MemStats()/PackageStats()/ID() というメソッドが存在するが debug ページの表示にしか使われていないため除外する。 // cf. https://github.com/golang/tools/tree/master/internal/lsp/debug

source.FileHandle

GetFile() に返される source.FileHandle とは次で定義される interface 型である。

type FileHandle interface {
    URI() span.URI
    Kind() FileKind
    FileIdentity() FileIdentity
    Read() ([]byte, error)
}

FileKind は対象のファイルが .go ファイルなのか go.mod なのか go.sum なのかを区別するための型で、FileIdentityURI とその中身の Hash を保持し、テキストを特定するための型である。
また Read() でファイルの中身を []byte で取り出せる。
つまりファイルそのものであると思える。

token.FileSet

cache のメソッドの解説に戻り、FileSet() が返す token.FileSet 型はこの手のソースコードを解析するコードを書く人にはおなじみの型で、複数ファイルのファイル上での位置情報を保持してくれる型である。
位置情報の参照がしたいときに使う、はず。
// なお私はそういうコードまだあまり書かないので別におなじみではない。

CacheFileSet() メソッドで token.FileSet を返すが、これは保持しているだけで、FileSetFile を追加するなどの操作をするのは、呼び出し側の仕事のようである。

GetFile()

CacheGetFile() の実装を読んでみる。

func (c *Cache) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error) {
    fi, statErr := os.Stat(uri.Filename())
    if statErr != nil {
        return &fileHandle{err: statErr}, nil
    }

    fh, ok := c.fileContent[uri]
    if ok && fh.modTime.Equal(fi.ModTime()) {
        return fh, nil
    }

    fh = readFile(ctx, uri, fi.ModTime())

    c.fileContent[uri] = fh

    return fh, nil
}

func readFile(ctx context.Context, uri span.URI, modTime time.Time) *fileHandle {
    data, err := ioutil.ReadFile(uri.Filename())
    if err != nil {
        return &fileHandle{
            modTime: modTime,
            err:     err,
        }
    }

    return &fileHandle{
        modTime: modTime,
        uri:     uri,
        bytes:   data,
        hash:    hashContents(data),
    }
}

これは並行処理のための Lock などを省略したコードだが、やっていることは同じ、はず。

  • 指定されたファイルが存在しなければ即時エラーを格納して返却
  • cache に存在すれば、ディスク上のファイルと最終更新日時を比較して一致していればそれを返す
  • 存在しなければ、ディスクからファイルを読み込み、メモリに cache する

ここまでの内容から CacheGetFile() されたファイルの内容と token.FileSet を保持する素朴な意味での cache であることが分かる。

Session

Session はエディタへのコネクション情報を保持する。
例えば、編集したファイルを(overlays という名前で) 保持している。
らしいがこれまた読んでもよく分からんので定義をあさる。

これは internal/lsp/source パッケージに interface が定義され、実装は internal/lsp/cache パッケージに存在する。

型定義

まずは internal/lsp/source パッケージにある 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)
}

SessionCacheNewSession() から生成され、自身を生成した Cache を保持することが期待されており、その Cache を生で操作するための Cache() というメソッドもあるが、これは debug 用とのことなので省略した。

NewView()DidModifyFiles() で取得される Snapshot 型は View を生成した瞬間、やファイルを変更した瞬間のスナップショットなのだが、これは恐らく利便性のためにここで返しているだけで、本来は View との関連が強い型なので詳細は View の解説の中で紹介する。

この実装である 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
}

Server 構造体が保持するのはこの Session 型(正確には source.Session interface)であり、Cache は間接的に操作される。

Overlay

Overlays() で返される Overlay 型は以下で定義される interface 型である。

type Overlay interface {
    VersionedFileHandle
    Saved() bool
}

VersionedFileHandle は前述の FileHandle にバージョン情報を付加したものである。

ここで、internal/lsp/cache パッケージ側の実装を読むと、Overlay が何であるかが少し分かる。

source.Session interface の実装である。cache.Session 型は overlays という map[span.URI]*overlay 型のフィールドを持つ。
この overlay 型が source.Overlay interface を満たす、internal/lsp/cache パッケージ内の実装である。

このフィールドに overlay が追加するためのメソッドは updateOverlays() であり、これは DidModifyFiles() からのみ呼ばれる。

updateOverlays() ではファイルへの変更をスライスで受け取り、最新のファイルの中身を overlays に更新している。
さらに、処理をよく読むと、変更種別が削除/保存でないときは s.cache.getFile() が呼ばれている。

つまり、

  • overlays には LSP クライアントから送信された TextDocument の中身
  • Cache には指定された URI のディスク上の中身

が保持されている。

もうすこし言葉を加えると、overlays は LSP でやりとりされる TextDocument の cache であり、Cache はそれに限らないディスク上のファイルの cache である。
なお、ここでもまだ Go ファイルとしてのパースは行われていない。

疲れたので今日はここまで。

次回は View を読む。

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

tennashi.hatenablog.com