From a13fc3fa3837c52e6f18f7b6fd6c407017c81220 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Thu, 2 Apr 2026 21:32:37 +0300 Subject: [PATCH 1/2] Debounde index updates --- internal/lsp/debounce.go | 62 ++++++++++++++++++++++++ internal/lsp/debounce_test.go | 91 +++++++++++++++++++++++++++++++++++ internal/lsp/handlers.go | 25 ++++++++-- internal/lsp/server.go | 4 ++ 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 internal/lsp/debounce.go create mode 100644 internal/lsp/debounce_test.go diff --git a/internal/lsp/debounce.go b/internal/lsp/debounce.go new file mode 100644 index 0000000..c2d729c --- /dev/null +++ b/internal/lsp/debounce.go @@ -0,0 +1,62 @@ +package lsp + +import ( + "sync" + "time" +) + +type documentDebouncer struct { + delay time.Duration + refresh func(string) + + mu sync.Mutex + timers map[string]*time.Timer +} + +func newDocumentDebouncer(delay time.Duration, refresh func(string)) *documentDebouncer { + return &documentDebouncer{ + delay: delay, + refresh: refresh, + timers: make(map[string]*time.Timer), + } +} + +func (d *documentDebouncer) Schedule(uri string) { + d.mu.Lock() + defer d.mu.Unlock() + + if timer, ok := d.timers[uri]; ok { + timer.Stop() + } + + var timer *time.Timer + timer = time.AfterFunc(d.delay, func() { + d.fire(uri, timer) + }) + + d.timers[uri] = timer +} + +func (d *documentDebouncer) Stop() { + d.mu.Lock() + defer d.mu.Unlock() + + for uri, timer := range d.timers { + timer.Stop() + delete(d.timers, uri) + } +} + +func (d *documentDebouncer) fire(uri string, timer *time.Timer) { + d.mu.Lock() + current, ok := d.timers[uri] + if !ok || current != timer { + d.mu.Unlock() + return + } + + delete(d.timers, uri) + d.mu.Unlock() + + d.refresh(uri) +} diff --git a/internal/lsp/debounce_test.go b/internal/lsp/debounce_test.go new file mode 100644 index 0000000..35ab8b5 --- /dev/null +++ b/internal/lsp/debounce_test.go @@ -0,0 +1,91 @@ +package lsp + +import ( + "testing" + "time" + + "github.com/tliron/glsp" + lsp "github.com/tliron/glsp/protocol_3_16" +) + +func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) { + events := make(chan string, 2) + debouncer := newDocumentDebouncer(20*time.Millisecond, func(uri string) { + events <- uri + }) + defer debouncer.Stop() + + debouncer.Schedule("file:///tmp/lets.yaml") + debouncer.Schedule("file:///tmp/lets.yaml") + + select { + case got := <-events: + if got != "file:///tmp/lets.yaml" { + t.Fatalf("refresh uri = %q, want %q", got, "file:///tmp/lets.yaml") + } + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for debounced refresh") + } + + select { + case got := <-events: + t.Fatalf("unexpected extra refresh for %q", got) + case <-time.After(60 * time.Millisecond): + } +} + +func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) { + server := &lspServer{ + storage: newStorage(), + parser: newParser(logger), + index: newIndex(logger), + log: logger, + } + server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument) + defer server.refresh.Stop() + + params := &lsp.DidChangeTextDocumentParams{ + TextDocument: lsp.VersionedTextDocumentIdentifier{ + TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"}, + }, + } + + params.ContentChanges = []any{ + lsp.TextDocumentContentChangeEventWhole{ + Text: `commands: + build: + cmd: echo build`, + }, + } + + if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil { + t.Fatalf("first textDocumentDidChange() error = %v", err) + } + + params.ContentChanges = []any{ + lsp.TextDocumentContentChangeEventWhole{ + Text: `commands: + release: + cmd: echo release`, + }, + } + + if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil { + t.Fatalf("second textDocumentDidChange() error = %v", err) + } + + deadline := time.Now().Add(300 * time.Millisecond) + for time.Now().Before(deadline) { + if _, ok := server.index.findCommand("release"); ok { + if _, ok := server.index.findCommand("build"); ok { + t.Fatal("expected stale command to be removed after debounced refresh") + } + + return + } + + time.Sleep(10 * time.Millisecond) + } + + t.Fatal("timed out waiting for debounced document refresh") +} diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 394e6c4..af66e83 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -35,6 +35,10 @@ func (s *lspServer) initialized(context *glsp.Context, params *lsp.InitializedPa } func (s *lspServer) shutdown(context *glsp.Context) error { + if s.refresh != nil { + s.refresh.Stop() + } + lsp.SetTraceValue(lsp.TraceValueOff) return nil } @@ -74,11 +78,20 @@ func (s *lspServer) loadMixins(uri string) { } } +func (s *lspServer) refreshDocument(uri string) { + doc := s.storage.GetDocument(uri) + if doc == nil { + return + } + + s.index.IndexDocument(uri, *doc) + s.loadMixins(uri) +} + func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error { s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text) - go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text) - go s.loadMixins(params.TextDocument.URI) + go s.refreshDocument(params.TextDocument.URI) return nil } @@ -88,9 +101,11 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did switch c := change.(type) { case lsp.TextDocumentContentChangeEventWhole: s.storage.AddDocument(params.TextDocument.URI, c.Text) - - go s.index.IndexDocument(params.TextDocument.URI, c.Text) - go s.loadMixins(params.TextDocument.URI) + if s.refresh != nil { + s.refresh.Schedule(params.TextDocument.URI) + } else { + go s.refreshDocument(params.TextDocument.URI) + } case lsp.TextDocumentContentChangeEvent: return errors.New("incremental changes not supported") } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 3e664f8..b5e60f9 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2,6 +2,7 @@ package lsp import ( "context" + "time" "github.com/lets-cli/lets/internal/env" "github.com/tliron/commonlog" @@ -11,6 +12,7 @@ import ( ) const lsName = "lets_ls" +const documentRefreshDebounce = 200 * time.Millisecond var handler lsp.Handler @@ -20,6 +22,7 @@ type lspServer struct { storage *storage parser *parser index *index + refresh *documentDebouncer log commonlog.Logger } @@ -60,6 +63,7 @@ func Run(ctx context.Context, version string) error { index: newIndex(logger), log: logger, } + lspServer.refresh = newDocumentDebouncer(documentRefreshDebounce, lspServer.refreshDocument) handler.Initialize = lspServer.initialize handler.Initialized = lspServer.initialized From 2aa480440823a05e838e60bbc13717746cd18d66 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 3 Apr 2026 11:37:41 +0300 Subject: [PATCH 2/2] Optimize LSP mixin loading and updates - Store and track mixin filenames per URI in storage - Avoid reading files that are already cached in storage (opened by editor or loaded previously) - Only read newly added mixins during updates - Bump document refresh debounce to 500ms to reduce index jitter --- internal/lsp/debounce.go | 2 ++ internal/lsp/handlers.go | 24 ++++++++++++++++++++++-- internal/lsp/server.go | 6 ++++-- internal/lsp/storage.go | 16 ++++++++++++++++ internal/settings/settings.go | 2 ++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/internal/lsp/debounce.go b/internal/lsp/debounce.go index c2d729c..50e56bf 100644 --- a/internal/lsp/debounce.go +++ b/internal/lsp/debounce.go @@ -30,6 +30,7 @@ func (d *documentDebouncer) Schedule(uri string) { } var timer *time.Timer + timer = time.AfterFunc(d.delay, func() { d.fire(uri, timer) }) @@ -49,6 +50,7 @@ func (d *documentDebouncer) Stop() { func (d *documentDebouncer) fire(uri string, timer *time.Timer) { d.mu.Lock() + current, ok := d.timers[uri] if !ok || current != timer { d.mu.Unlock() diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index af66e83..bcaf868 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -40,6 +40,7 @@ func (s *lspServer) shutdown(context *glsp.Context) error { } lsp.SetTraceValue(lsp.TraceValueOff) + return nil } @@ -57,8 +58,25 @@ func (s *lspServer) loadMixins(uri string) { path := normalizePath(uri) - for _, filename := range s.parser.getMixinFilenames(doc) { + currentMixins := s.parser.getMixinFilenames(doc) + previousMixins := s.storage.GetMixins(uri) + + s.storage.SetMixins(uri, currentMixins) + + for _, filename := range currentMixins { + if slices.Contains(previousMixins, filename) { + continue + } + mixinPath := replacePathFilename(path, strings.TrimPrefix(filename, "-")) + mixinURI := pathToURI(mixinPath) + + // skip if the document is already managed by the storage + // e.g. opened manually in the editor or already loaded + if s.storage.GetDocument(mixinURI) != nil { + continue + } + if !util.FileExists(mixinPath) { s.log.Debugf("mixin target does not exist: %s", mixinPath) continue @@ -70,7 +88,8 @@ func (s *lspServer) loadMixins(uri string) { continue } - mixinURI := pathToURI(mixinPath) + s.log.Debugf("load mixin %s", mixinPath) + text := string(data) s.storage.AddDocument(mixinURI, text) @@ -101,6 +120,7 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did switch c := change.(type) { case lsp.TextDocumentContentChangeEventWhole: s.storage.AddDocument(params.TextDocument.URI, c.Text) + if s.refresh != nil { s.refresh.Schedule(params.TextDocument.URI) } else { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index b5e60f9..1f5ab7e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -11,8 +11,10 @@ import ( "github.com/tliron/glsp/server" ) -const lsName = "lets_ls" -const documentRefreshDebounce = 200 * time.Millisecond +const ( + lsName = "lets_ls" + documentRefreshDebounce = 500 * time.Millisecond +) var handler lsp.Handler diff --git a/internal/lsp/storage.go b/internal/lsp/storage.go index 46b5d23..46b45ab 100644 --- a/internal/lsp/storage.go +++ b/internal/lsp/storage.go @@ -5,11 +5,13 @@ import "sync" type storage struct { mu sync.RWMutex documents map[string]*string + mixins map[string][]string } func newStorage() *storage { return &storage{ documents: make(map[string]*string), + mixins: make(map[string][]string), } } @@ -26,3 +28,17 @@ func (s *storage) AddDocument(uri string, text string) { s.documents[uri] = &text } + +func (s *storage) SetMixins(uri string, mixins []string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.mixins[uri] = mixins +} + +func (s *storage) GetMixins(uri string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.mixins[uri] +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index d2be135..fba23d2 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -51,8 +51,10 @@ func LoadFile(path string) (Settings, error) { defer file.Close() var fileSettings FileSettings + decoder := yaml.NewDecoder(file) decoder.KnownFields(true) + if err := decoder.Decode(&fileSettings); err != nil { return Settings{}, fmt.Errorf("failed to decode settings file: %w", err) }