diff --git a/internal/lsp/debounce.go b/internal/lsp/debounce.go new file mode 100644 index 0000000..50e56bf --- /dev/null +++ b/internal/lsp/debounce.go @@ -0,0 +1,64 @@ +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..bcaf868 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -35,7 +35,12 @@ 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 } @@ -53,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 @@ -66,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) @@ -74,11 +97,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 } @@ -89,8 +121,11 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did 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..1f5ab7e 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" @@ -10,7 +11,10 @@ import ( "github.com/tliron/glsp/server" ) -const lsName = "lets_ls" +const ( + lsName = "lets_ls" + documentRefreshDebounce = 500 * time.Millisecond +) var handler lsp.Handler @@ -20,6 +24,7 @@ type lspServer struct { storage *storage parser *parser index *index + refresh *documentDebouncer log commonlog.Logger } @@ -60,6 +65,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 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) }