Skip to content

Commit 02fa877

Browse files
authored
Merge pull request #332 from lets-cli/optimize-index-updates
Optimize index updates
2 parents 5dc94bb + 2aa4804 commit 02fa877

6 files changed

Lines changed: 221 additions & 7 deletions

File tree

internal/lsp/debounce.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package lsp
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
type documentDebouncer struct {
9+
delay time.Duration
10+
refresh func(string)
11+
12+
mu sync.Mutex
13+
timers map[string]*time.Timer
14+
}
15+
16+
func newDocumentDebouncer(delay time.Duration, refresh func(string)) *documentDebouncer {
17+
return &documentDebouncer{
18+
delay: delay,
19+
refresh: refresh,
20+
timers: make(map[string]*time.Timer),
21+
}
22+
}
23+
24+
func (d *documentDebouncer) Schedule(uri string) {
25+
d.mu.Lock()
26+
defer d.mu.Unlock()
27+
28+
if timer, ok := d.timers[uri]; ok {
29+
timer.Stop()
30+
}
31+
32+
var timer *time.Timer
33+
34+
timer = time.AfterFunc(d.delay, func() {
35+
d.fire(uri, timer)
36+
})
37+
38+
d.timers[uri] = timer
39+
}
40+
41+
func (d *documentDebouncer) Stop() {
42+
d.mu.Lock()
43+
defer d.mu.Unlock()
44+
45+
for uri, timer := range d.timers {
46+
timer.Stop()
47+
delete(d.timers, uri)
48+
}
49+
}
50+
51+
func (d *documentDebouncer) fire(uri string, timer *time.Timer) {
52+
d.mu.Lock()
53+
54+
current, ok := d.timers[uri]
55+
if !ok || current != timer {
56+
d.mu.Unlock()
57+
return
58+
}
59+
60+
delete(d.timers, uri)
61+
d.mu.Unlock()
62+
63+
d.refresh(uri)
64+
}

internal/lsp/debounce_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package lsp
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/tliron/glsp"
8+
lsp "github.com/tliron/glsp/protocol_3_16"
9+
)
10+
11+
func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) {
12+
events := make(chan string, 2)
13+
debouncer := newDocumentDebouncer(20*time.Millisecond, func(uri string) {
14+
events <- uri
15+
})
16+
defer debouncer.Stop()
17+
18+
debouncer.Schedule("file:///tmp/lets.yaml")
19+
debouncer.Schedule("file:///tmp/lets.yaml")
20+
21+
select {
22+
case got := <-events:
23+
if got != "file:///tmp/lets.yaml" {
24+
t.Fatalf("refresh uri = %q, want %q", got, "file:///tmp/lets.yaml")
25+
}
26+
case <-time.After(200 * time.Millisecond):
27+
t.Fatal("timed out waiting for debounced refresh")
28+
}
29+
30+
select {
31+
case got := <-events:
32+
t.Fatalf("unexpected extra refresh for %q", got)
33+
case <-time.After(60 * time.Millisecond):
34+
}
35+
}
36+
37+
func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
38+
server := &lspServer{
39+
storage: newStorage(),
40+
parser: newParser(logger),
41+
index: newIndex(logger),
42+
log: logger,
43+
}
44+
server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
45+
defer server.refresh.Stop()
46+
47+
params := &lsp.DidChangeTextDocumentParams{
48+
TextDocument: lsp.VersionedTextDocumentIdentifier{
49+
TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"},
50+
},
51+
}
52+
53+
params.ContentChanges = []any{
54+
lsp.TextDocumentContentChangeEventWhole{
55+
Text: `commands:
56+
build:
57+
cmd: echo build`,
58+
},
59+
}
60+
61+
if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil {
62+
t.Fatalf("first textDocumentDidChange() error = %v", err)
63+
}
64+
65+
params.ContentChanges = []any{
66+
lsp.TextDocumentContentChangeEventWhole{
67+
Text: `commands:
68+
release:
69+
cmd: echo release`,
70+
},
71+
}
72+
73+
if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil {
74+
t.Fatalf("second textDocumentDidChange() error = %v", err)
75+
}
76+
77+
deadline := time.Now().Add(300 * time.Millisecond)
78+
for time.Now().Before(deadline) {
79+
if _, ok := server.index.findCommand("release"); ok {
80+
if _, ok := server.index.findCommand("build"); ok {
81+
t.Fatal("expected stale command to be removed after debounced refresh")
82+
}
83+
84+
return
85+
}
86+
87+
time.Sleep(10 * time.Millisecond)
88+
}
89+
90+
t.Fatal("timed out waiting for debounced document refresh")
91+
}

internal/lsp/handlers.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ func (s *lspServer) initialized(context *glsp.Context, params *lsp.InitializedPa
3535
}
3636

3737
func (s *lspServer) shutdown(context *glsp.Context) error {
38+
if s.refresh != nil {
39+
s.refresh.Stop()
40+
}
41+
3842
lsp.SetTraceValue(lsp.TraceValueOff)
43+
3944
return nil
4045
}
4146

@@ -53,8 +58,25 @@ func (s *lspServer) loadMixins(uri string) {
5358

5459
path := normalizePath(uri)
5560

56-
for _, filename := range s.parser.getMixinFilenames(doc) {
61+
currentMixins := s.parser.getMixinFilenames(doc)
62+
previousMixins := s.storage.GetMixins(uri)
63+
64+
s.storage.SetMixins(uri, currentMixins)
65+
66+
for _, filename := range currentMixins {
67+
if slices.Contains(previousMixins, filename) {
68+
continue
69+
}
70+
5771
mixinPath := replacePathFilename(path, strings.TrimPrefix(filename, "-"))
72+
mixinURI := pathToURI(mixinPath)
73+
74+
// skip if the document is already managed by the storage
75+
// e.g. opened manually in the editor or already loaded
76+
if s.storage.GetDocument(mixinURI) != nil {
77+
continue
78+
}
79+
5880
if !util.FileExists(mixinPath) {
5981
s.log.Debugf("mixin target does not exist: %s", mixinPath)
6082
continue
@@ -66,19 +88,29 @@ func (s *lspServer) loadMixins(uri string) {
6688
continue
6789
}
6890

69-
mixinURI := pathToURI(mixinPath)
91+
s.log.Debugf("load mixin %s", mixinPath)
92+
7093
text := string(data)
7194

7295
s.storage.AddDocument(mixinURI, text)
7396
s.index.IndexDocument(mixinURI, text)
7497
}
7598
}
7699

100+
func (s *lspServer) refreshDocument(uri string) {
101+
doc := s.storage.GetDocument(uri)
102+
if doc == nil {
103+
return
104+
}
105+
106+
s.index.IndexDocument(uri, *doc)
107+
s.loadMixins(uri)
108+
}
109+
77110
func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error {
78111
s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text)
79112

80-
go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text)
81-
go s.loadMixins(params.TextDocument.URI)
113+
go s.refreshDocument(params.TextDocument.URI)
82114

83115
return nil
84116
}
@@ -89,8 +121,11 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
89121
case lsp.TextDocumentContentChangeEventWhole:
90122
s.storage.AddDocument(params.TextDocument.URI, c.Text)
91123

92-
go s.index.IndexDocument(params.TextDocument.URI, c.Text)
93-
go s.loadMixins(params.TextDocument.URI)
124+
if s.refresh != nil {
125+
s.refresh.Schedule(params.TextDocument.URI)
126+
} else {
127+
go s.refreshDocument(params.TextDocument.URI)
128+
}
94129
case lsp.TextDocumentContentChangeEvent:
95130
return errors.New("incremental changes not supported")
96131
}

internal/lsp/server.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package lsp
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/lets-cli/lets/internal/env"
78
"github.com/tliron/commonlog"
@@ -10,7 +11,10 @@ import (
1011
"github.com/tliron/glsp/server"
1112
)
1213

13-
const lsName = "lets_ls"
14+
const (
15+
lsName = "lets_ls"
16+
documentRefreshDebounce = 500 * time.Millisecond
17+
)
1418

1519
var handler lsp.Handler
1620

@@ -20,6 +24,7 @@ type lspServer struct {
2024
storage *storage
2125
parser *parser
2226
index *index
27+
refresh *documentDebouncer
2328
log commonlog.Logger
2429
}
2530

@@ -60,6 +65,7 @@ func Run(ctx context.Context, version string) error {
6065
index: newIndex(logger),
6166
log: logger,
6267
}
68+
lspServer.refresh = newDocumentDebouncer(documentRefreshDebounce, lspServer.refreshDocument)
6369

6470
handler.Initialize = lspServer.initialize
6571
handler.Initialized = lspServer.initialized

internal/lsp/storage.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import "sync"
55
type storage struct {
66
mu sync.RWMutex
77
documents map[string]*string
8+
mixins map[string][]string
89
}
910

1011
func newStorage() *storage {
1112
return &storage{
1213
documents: make(map[string]*string),
14+
mixins: make(map[string][]string),
1315
}
1416
}
1517

@@ -26,3 +28,17 @@ func (s *storage) AddDocument(uri string, text string) {
2628

2729
s.documents[uri] = &text
2830
}
31+
32+
func (s *storage) SetMixins(uri string, mixins []string) {
33+
s.mu.Lock()
34+
defer s.mu.Unlock()
35+
36+
s.mixins[uri] = mixins
37+
}
38+
39+
func (s *storage) GetMixins(uri string) []string {
40+
s.mu.RLock()
41+
defer s.mu.RUnlock()
42+
43+
return s.mixins[uri]
44+
}

internal/settings/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ func LoadFile(path string) (Settings, error) {
5151
defer file.Close()
5252

5353
var fileSettings FileSettings
54+
5455
decoder := yaml.NewDecoder(file)
5556
decoder.KnownFields(true)
57+
5658
if err := decoder.Decode(&fileSettings); err != nil {
5759
return Settings{}, fmt.Errorf("failed to decode settings file: %w", err)
5860
}

0 commit comments

Comments
 (0)