Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: generic and transparent LSP request caching
1. with saving/loading cache to/from disk
2. fixes the missing client.Close
  • Loading branch information
Hoblovski committed Sep 8, 2025
commit 7b7e9112785a159cf927cbbfe28abc488cbfbc7a
127 changes: 127 additions & 0 deletions lang/lsp/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2025 CloudWeGo Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package lsp

import (
"context"
"encoding/json"
"os"
"sync"
"time"

"github.com/cloudwego/abcoder/lang/log"
)

type LSPRequestCache struct {
cachePath string
cacheInterval int
mu sync.Mutex
cache map[string]map[string]json.RawMessage // method -> params -> result
cancel context.CancelFunc
}

func NewLSPRequestCache(path string, interval int) *LSPRequestCache {
c := &LSPRequestCache{
cachePath: path,
cacheInterval: interval,
cache: make(map[string]map[string]json.RawMessage),
}
c.Init()
return c
}

func (c *LSPRequestCache) Init() {
if c.cachePath == "" {
return
}
if err := c.loadCacheFromDisk(); err != nil {
log.Error("failed to load LSP cache from disk: %v", err)
} else {
log.Info("LSP cache loaded from disk")
}
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
go c.PeriodicCacheSaver(ctx)
}

func (c *LSPRequestCache) Close() {
if c.cancel != nil {
c.cancel()
}
}

func (c *LSPRequestCache) saveCacheToDisk() error {
c.mu.Lock()
defer c.mu.Unlock()
data, err := json.Marshal(c.cache)
if err != nil {
return err
}
return os.WriteFile(c.cachePath, data, 0644)
}

func (c *LSPRequestCache) loadCacheFromDisk() error {
data, err := os.ReadFile(c.cachePath)
if err != nil {
return err
}
if err := json.Unmarshal(data, &c.cache); err != nil {
return err
}
return nil
}

func (cli *LSPRequestCache) PeriodicCacheSaver(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Duration(cli.cacheInterval) * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
if err := cli.saveCacheToDisk(); err != nil {
log.Error("failed to save LSP cache to disk: %v", err)
} else {
log.Info("LSP cache saved to disk")
}
case <-ctx.Done():
log.Info("LSP cache saver cancelled, shutting down.")
return
}
}
}()
}

func (cli *LSPRequestCache) Get(method, params string) (json.RawMessage, bool) {
cli.mu.Lock()
defer cli.mu.Unlock()
if methodCache, ok := cli.cache[method]; ok {
if result, ok := methodCache[params]; ok {
return result, true
}
}
return nil, false
}

func (cli *LSPRequestCache) Set(method, params string, result json.RawMessage) {
cli.mu.Lock()
defer cli.mu.Unlock()
methodCache, ok := cli.cache[method]
if !ok {
methodCache = make(map[string]json.RawMessage)
cli.cache[method] = methodCache
}
methodCache[params] = result
}
24 changes: 20 additions & 4 deletions lang/lsp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ type LSPClient struct {
tokenModifiers []string
hasSemanticTokensRange bool
files map[DocumentURI]*TextDocumentItem
cache *LSPRequestCache
ClientOptions
}

type ClientOptions struct {
Server string
uniast.Language
Verbose bool
// for lsp cache
LSPCachePath string
LSPCacheInterval int
}

func NewLSPClient(repo string, openfile string, wait time.Duration, opts ClientOptions) (*LSPClient, error) {
Expand All @@ -60,6 +64,7 @@ func NewLSPClient(repo string, openfile string, wait time.Duration, opts ClientO

cli.ClientOptions = opts
cli.files = make(map[DocumentURI]*TextDocumentItem)
cli.cache = NewLSPRequestCache(opts.LSPCachePath, opts.LSPCacheInterval)

if openfile != "" {
_, err := cli.DidOpen(context.Background(), NewURI(openfile))
Expand All @@ -74,17 +79,28 @@ func NewLSPClient(repo string, openfile string, wait time.Duration, opts ClientO
}

func (c *LSPClient) Close() error {
c.cache.Close()
c.lspHandler.Close()
return c.Conn.Close()
}

// Extra wrapper around json rpc to
// 1. implement a transparent, generic cache
// By default all client requests are cached.
// To use non-cached version, use `cli.Conn.Call` directly.
func (cli *LSPClient) Call(ctx context.Context, method string, params, result interface{}, opts ...jsonrpc2.CallOption) error {
var raw json.RawMessage
if err := cli.Conn.Call(ctx, method, params, &raw); err != nil {
paramsMarshal, err := json.Marshal(params)
if err != nil {
log.Error("LSPClient.Call: marshal params error: %v", err)
return err
}
paramsStr := string(paramsMarshal)
var raw json.RawMessage
var ok bool
if raw, ok = cli.cache.Get(method, paramsStr); !ok {
if err = cli.Conn.Call(ctx, method, params, &raw); err != nil {
return err
}
cli.cache.Set(method, paramsStr, raw)
}
if err := json.Unmarshal(raw, result); err != nil {
return err
}
Expand Down
14 changes: 10 additions & 4 deletions lang/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ type ParseOptions struct {
collect.CollectOption
// specify the repo id
RepoID string
// The path used for caching LSP requests
LSPCachePath string
// The interval (in seconds) for caching LSP requests
LSPCacheInterval int

// TS options
// tsconfig string
Expand Down Expand Up @@ -76,14 +80,16 @@ func Parse(ctx context.Context, uri string, args ParseOptions) ([]byte, error) {
log.Info("start initialize LSP server %s...\n", lspPath)
var err error
client, err = lsp.NewLSPClient(uri, openfile, opentime, lsp.ClientOptions{
Server: lspPath,
Language: l,
Verbose: args.Verbose,
})
Server: lspPath,
Language: l,
Verbose: args.Verbose,
LSPCachePath: args.LSPCachePath,
LSPCacheInterval: args.LSPCacheInterval})
if err != nil {
log.Error("failed to initialize LSP server: %v\n", err)
return nil, err
}
defer client.Close()
log.Info("end initialize LSP server")
}

Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func main() {
flags.BoolVar(&opts.LoadByPackages, "load-by-packages", false, "load by packages (only works for Go now)")
flags.Var((*StringArray)(&opts.Excludes), "exclude", "exclude files or directories, support multiple values")
flags.Var((*StringArray)(&opts.Includes), "include", "include files or directories, support multiple values")
flags.StringVar(&opts.LSPCachePath, "lsp-cache-path", "", "the path used for caching LSP requests (set to empty to disable saving cache to disk)")
flags.IntVar(&opts.LSPCacheInterval, "lsp-cache-interval", 30, "the interval (in seconds) for caching LSP requests")
flags.StringVar(&opts.RepoID, "repo-id", "", "specify the repo id")
flags.StringVar(&opts.TSConfig, "tsconfig", "", "tsconfig path (only works for TS now)")
flags.Var((*StringArray)(&opts.TSSrcDir), "ts-src-dir", "src-dir path (only works for TS now)")
Expand Down