all repos

clerk @ d3fcad3049dc9b55eb775f22fdfb98d83101cd26

missing tooling for ledger/hledger

clerk/internal/lsp/textdocument_semantic_tokens_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: incremental semantic tokens update, 1 month ago
1
package lsp
2
3
import (
4
	"fmt"
5
	"os"
6
	"slices"
7
	"strings"
8
	"testing"
9
10
	"go.lsp.dev/protocol"
11
	"go.lsp.dev/uri"
12
13
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
14
	"olexsmir.xyz/clerk/internal/testutil/golden"
15
)
16
17
func TestEncodeSemTokens(t *testing.T) {
18
	tests := map[string]struct {
19
		tokens []semanticToken
20
		want   []uint32
21
	}{
22
		"nil":   {nil, nil},
23
		"empty": {[]semanticToken{}, nil},
24
		"single": {[]semanticToken{
25
			{line: 0, col: 0, length: 4, tokenType: semDate},
26
		}, []uint32{0, 0, 4, semDate, 0}},
27
		"line 0 col 0": {[]semanticToken{
28
			{line: 0, col: 0, length: 1, tokenType: semDirective},
29
		}, []uint32{0, 0, 1, semDirective, 0}},
30
		"multiple": {[]semanticToken{
31
			{line: 0, col: 0, length: 10, tokenType: semDate},
32
			{line: 0, col: 11, length: 5, tokenType: semString},
33
			{line: 1, col: 4, length: 10, tokenType: semAccount},
34
		}, []uint32{
35
			0, 0, 10, semDate, 0,
36
			0, 11, 5, semString, 0,
37
			1, 4, 10, semAccount, 0,
38
		}},
39
		// input must be sorted by line and column (rawToSemanticTokens output);
40
		// deltas would underflow otherwise
41
		"sorted": {[]semanticToken{
42
			{line: 0, col: 0, length: 10, tokenType: semDate},
43
			{line: 0, col: 32, length: 2, tokenType: semAmount},
44
			{line: 0, col: 36, length: 3, tokenType: semCommodity},
45
			{line: 1, col: 4, length: 6, tokenType: semAccount},
46
		}, []uint32{
47
			0, 0, 10, semDate, 0,
48
			0, 32, 2, semAmount, 0,
49
			0, 4, 3, semCommodity, 0,
50
			1, 4, 6, semAccount, 0,
51
		}},
52
	}
53
54
	for tname, tt := range tests {
55
		t.Run(tname, func(t *testing.T) {
56
			if got := encodeSemTokens(tt.tokens); !slices.Equal(got, tt.want) {
57
				t.Errorf("encodeSemTokens() = %v, want %v", got, tt.want)
58
			}
59
		})
60
	}
61
}
62
63
func TestSemanticTokensEdits(t *testing.T) {
64
	tests := map[string]struct{ old, new []uint32 }{
65
		"identical":      {[]uint32{1, 2, 3}, []uint32{1, 2, 3}},
66
		"both empty":     {},
67
		"empty to non":   {nil, []uint32{1, 2}},
68
		"non to empty":   {[]uint32{1, 2}, nil},
69
		"pure insert":    {[]uint32{1, 2}, []uint32{1, 2, 3, 4}},
70
		"pure delete":    {[]uint32{1, 2, 3, 4}, []uint32{1, 2}},
71
		"replace middle": {[]uint32{1, 2, 3, 4, 5}, []uint32{1, 2, 9, 4, 5}},
72
		"replace all":    {[]uint32{1, 2}, []uint32{3, 4}},
73
		"replace tail":   {[]uint32{1, 2, 3, 4, 5}, []uint32{1, 2, 3, 4, 6}},
74
	}
75
	for tname, tt := range tests {
76
		t.Run(tname, func(t *testing.T) {
77
			edits := semanticTokensEdits(tt.old, tt.new)
78
			if got := applySemEdits(tt.old, edits); !slices.Equal(got, tt.new) {
79
				t.Errorf("apply(%v, %v) = %v, want %v", tt.old, edits, got, tt.new)
80
			}
81
			for _, e := range edits {
82
				if e.Start+e.DeleteCount > uint32(len(tt.old)) {
83
					t.Errorf("edit %+v out of bounds for old length %d", e, len(tt.old))
84
				}
85
			}
86
		})
87
	}
88
}
89
90
func TestServer_Semantic_EmptyDocument(t *testing.T) {
91
	srv := newServer(t)
92
	srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal")
93
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
94
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")},
95
	})
96
	if err != nil {
97
		t.Fatal(err)
98
	}
99
	if len(result.Data) != 0 {
100
		t.Errorf("expected empty data for empty doc, got %d values", len(result.Data))
101
	}
102
}
103
104
func TestServer_Semantic_DocumentNotFound(t *testing.T) {
105
	result, err := newServer(t).server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
106
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")},
107
	})
108
	if err != nil {
109
		t.Fatal(err)
110
	}
111
	if result == nil {
112
		t.Fatal("result is nil")
113
	}
114
	if result.Data != nil {
115
		t.Errorf("expected nil Data for unknown doc, got %v", result.Data)
116
	}
117
}
118
119
func TestGolden_SemanticTokens(t *testing.T) {
120
	for _, tt := range []string{"semantic-empty", "semantic-journal", "semantic-directives", "semantic-unparseable", "semantic-with-errors"} {
121
		ar := golden.Read(t, tt)
122
123
		t.Run(tt+"_golden", func(t *testing.T) {
124
			toks := renderSemanticTokens(tokSem(ar.Get("in.journal")))
125
			golden.Assert(t, ar, toks)
126
		})
127
128
		t.Run(tt+"_no-overlap", func(t *testing.T) {
129
			assertGoldenNoOverlap(t, tt, ar)
130
		})
131
	}
132
}
133
134
func TestGolden_SemanticTokensRange(t *testing.T) {
135
	ar := golden.Read(t, "semantic-range")
136
	in := ar.Get("in.journal")
137
138
	t.Run("no-overlap", func(t *testing.T) {
139
		assertGoldenNoOverlap(t, "semantic-range", ar)
140
	})
141
142
	t.Run("golden", func(t *testing.T) {
143
		u := uri.URI("file:///test.journal")
144
		srv := newServer(t)
145
		srv.server.openDoc(u, string(in), 1, "journal")
146
147
		var out strings.Builder
148
		for line := range strings.SplitSeq(string(ar.Get("ranges.txt")), "\n") {
149
			if line == "" {
150
				continue
151
			}
152
			var name string
153
			var start, end uint32
154
			if _, err := fmt.Sscanf(line, "%s %d %d", &name, &start, &end); err != nil {
155
				t.Fatalf("ranges.txt: %q: %v", line, err)
156
			}
157
158
			res, err := srv.server.SemanticTokensRange(t.Context(), &protocol.SemanticTokensRangeParams{
159
				TextDocument: protocol.TextDocumentIdentifier{URI: u},
160
				Range: protocol.Range{
161
					Start: protocol.Position{Line: start},
162
					End:   protocol.Position{Line: end},
163
				},
164
			})
165
			if err != nil {
166
				t.Fatal(err)
167
			}
168
			tokens := decodeSemTokens(res.Data)
169
			for _, tok := range tokens {
170
				if tok.line < start || tok.line > end {
171
					t.Fatalf("%s: token %d:%d outside range lines [%d,%d]", name, tok.line, tok.col, start, end)
172
				}
173
			}
174
			fmt.Fprintf(&out, "== %s ==\n", name)
175
			out.WriteString(renderSemanticTokens(tokens))
176
		}
177
		golden.Assert(t, ar, out.String())
178
	})
179
}
180
181
func TestGolden_SemanticTokensDelta(t *testing.T) {
182
	for _, tt := range []string{"semantic-delta-edit", "semantic-delta-nochange", "semantic-delta-stale"} {
183
		ar := golden.Read(t, tt)
184
185
		t.Run(tt+"_no-overlap", func(t *testing.T) {
186
			assertGoldenNoOverlap(t, tt, ar)
187
		})
188
189
		t.Run(tt, func(t *testing.T) {
190
			in := ar.Get("in.journal")
191
192
			u := uri.URI("file:///test.journal")
193
			srv := newServer(t)
194
			srv.server.openDoc(u, string(in), 1, "journal")
195
196
			full, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
197
				TextDocument: protocol.TextDocumentIdentifier{URI: u},
198
			})
199
			if err != nil {
200
				t.Fatal(err)
201
			}
202
			if full.ResultID == nil || *full.ResultID == "" {
203
				t.Fatal("expected a resultId on the full result")
204
			}
205
206
			finalText := in
207
			if ed := ar.Get("edited.journal"); ed != nil {
208
				srv.server.updateDoc(u, 2, partialChangeFromDiff(string(in), string(ed)))
209
				finalText = ed
210
			}
211
			prev := *full.ResultID
212
			if p := ar.Get("prev-id"); p != nil {
213
				prev = strings.TrimSpace(string(p))
214
			}
215
216
			res, err := srv.server.SemanticTokensFullDelta(t.Context(), &protocol.SemanticTokensDeltaParams{
217
				TextDocument:     protocol.TextDocumentIdentifier{URI: u},
218
				PreviousResultID: prev,
219
			})
220
			if err != nil {
221
				t.Fatal(err)
222
			}
223
224
			var client []uint32
225
			var out strings.Builder
226
			switch r := res.(type) {
227
			case *protocol.SemanticTokensDelta:
228
				fmt.Fprintf(&out, "delta resultId %s\n", *r.ResultID)
229
				client = applySemEdits(full.Data, r.Edits)
230
			case *protocol.SemanticTokens:
231
				fmt.Fprintf(&out, "full resultId %s\n", *r.ResultID)
232
				client = r.Data
233
			default:
234
				t.Fatalf("unexpected result type %T", res)
235
			}
236
237
			// The client's token state must equal the final text's tokens: an
238
			// independent reference that keeps the golden from capturing bugs.
239
			if want := encodeSemTokens(tokSem(finalText)); !slices.Equal(client, want) {
240
				t.Errorf("delta flow produced %d elems, want %d", len(client), len(want))
241
			}
242
243
			out.WriteString(renderSemanticTokens(tokSem(finalText)))
244
			golden.Assert(t, ar, out.String())
245
		})
246
	}
247
}
248
249
func assertGoldenNoOverlap(t *testing.T, tt string, ar *golden.Archive) {
250
	t.Helper()
251
	for _, f := range ar.Files {
252
		if strings.HasSuffix(f.Name, ".journal") {
253
			assertNoOverlap(t, tt, tokSem(f.Data))
254
		}
255
	}
256
}
257
258
func assertNoOverlap(t *testing.T, tt string, toks []semanticToken) {
259
	t.Helper()
260
	slices.SortFunc(toks, func(a, b semanticToken) int {
261
		if a.line != b.line {
262
			return int(a.line) - int(b.line)
263
		}
264
		return int(a.col) - int(b.col)
265
	})
266
	for i := 1; i < len(toks); i++ {
267
		prev, cur := toks[i-1], toks[i]
268
		if prev.line != cur.line {
269
			continue
270
		}
271
		if cur.col < prev.col+prev.length {
272
			t.Errorf("%s: overlapping tokens on line %d: %s@%d+%d then %s@%d+%d",
273
				tt, prev.line, tokenTypeStrings[prev.tokenType], prev.col, prev.length,
274
				tokenTypeStrings[cur.tokenType], cur.col, cur.length)
275
		}
276
	}
277
}
278
279
func renderSemanticTokens(tokens []semanticToken) string {
280
	slices.SortFunc(tokens, func(a, b semanticToken) int {
281
		if a.line != b.line {
282
			return int(a.line) - int(b.line)
283
		}
284
		return int(a.col) - int(b.col)
285
	})
286
	var b strings.Builder
287
	for _, tok := range tokens {
288
		fmt.Fprintf(&b, "%d:%d+%d %s", tok.line, tok.col, tok.length, tokenTypeStrings[tok.tokenType])
289
		for i, m := range modifierStrings {
290
			if tok.modifiers&(1<<uint(i)) != 0 {
291
				b.WriteByte(' ')
292
				b.WriteString(m)
293
			}
294
		}
295
		b.WriteByte('\n')
296
	}
297
	return b.String()
298
}
299
300
func decodeSemTokens(data []uint32) []semanticToken {
301
	var out []semanticToken
302
	line, col := 0, 0
303
	for i := 0; i+4 < len(data); i += 5 {
304
		if data[i] > 0 {
305
			line += int(data[i])
306
			col = int(data[i+1])
307
		} else {
308
			col += int(data[i+1])
309
		}
310
		out = append(out, semanticToken{
311
			line:      uint32(line),
312
			col:       uint32(col),
313
			length:    data[i+2],
314
			tokenType: data[i+3],
315
			modifiers: data[i+4],
316
		})
317
	}
318
	return out
319
}
320
321
func tokSem(content []byte) []semanticToken {
322
	c := string(content)
323
	return tokenizeForSemantics(c, parseJournalStr(c))
324
}
325
326
func TestSemanticTokensIncremental(t *testing.T) {
327
	for _, tt := range []string{"semantic-incremental-append", "semantic-incremental-insert", "semantic-incremental-delete", "semantic-incremental-inline", "semantic-incremental-header"} {
328
		t.Run(tt, func(t *testing.T) {
329
			ar := golden.Read(t, tt)
330
			in, edited := ar.Get("in.journal"), ar.Get("edited.journal")
331
332
			// golden: the server's incremental result equals a full tokenization
333
			golden.Assert(t, ar, renderSemanticTokens(serverSemTokensAfterEdit(t, newServer(t).server, string(in), string(edited))))
334
335
			// engage: the incremental path actually runs, not a silent full rebuild
336
			ev := partialChangeFromDiff(string(in), string(edited))[0].(*protocol.TextDocumentContentChangePartial)
337
			start, oldEnd, newEnd, delta := editRegion(string(in), ev)
338
			_, entries := computeSemTokens(string(in), parseJournalStr(string(in)))
339
			if _, ok := incrementalTokens(string(edited), parseJournalStr(string(edited)), entries, semEdit{start: start, oldEnd: oldEnd, newEnd: newEnd, deltaLine: delta}); !ok {
340
				t.Error("incremental path not taken")
341
			}
342
		})
343
	}
344
345
	t.Run("recover", func(t *testing.T) {
346
		ar := golden.Read(t, "semantic-incremental-recover")
347
		bad, good := ar.Get("in.journal"), ar.Get("edited.journal")
348
		golden.Assert(t, ar, renderSemanticTokens(serverSemTokensAfterEdit(t, newServer(t).server, string(bad), string(good))))
349
	})
350
}
351
352
func BenchmarkSemanticTokens(b *testing.B) {
353
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
354
355
	// Cold path: each iteration re-parses and re-encodes, as after an edit.
356
	b.ReportAllocs()
357
	b.ResetTimer()
358
	for b.Loop() {
359
		tokens := tokenizeForSemantics(content, parseJournalStr(content))
360
		_ = encodeSemTokens(tokens)
361
	}
362
}
363
364
// BenchmarkSemanticTokensDelta measures the cost of one dela response after an edit.
365
func BenchmarkSemanticTokensDelta(b *testing.B) {
366
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
367
	prev := encodeSemTokens(tokenizeForSemantics(content, parseJournalStr(content)))
368
	edited := content + "\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n"
369
370
	b.ReportAllocs()
371
	b.ResetTimer()
372
	for b.Loop() {
373
		data := encodeSemTokens(tokenizeForSemantics(edited, parseJournalStr(edited)))
374
		_ = semanticTokensEdits(prev, data)
375
	}
376
}
377
378
func BenchmarkSemanticTokensEdits(b *testing.B) {
379
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
380
	old := encodeSemTokens(tokenizeForSemantics(content, parseJournalStr(content)))
381
	new_ := encodeSemTokens(tokenizeForSemantics(
382
		content+"\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n",
383
		parseJournalStr(content+"\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n"),
384
	))
385
386
	b.ReportAllocs()
387
	b.ResetTimer()
388
	for b.Loop() {
389
		_ = semanticTokensEdits(old, new_)
390
	}
391
}
392
393
func BenchmarkIncrementalTokens(b *testing.B) {
394
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
395
	_, entries := computeSemTokens(content, parseJournalStr(content))
396
397
	newTx := "\n2024-01-01 new  $1\n    expenses:new  $1\n    assets:cash"
398
	appendEdit := content + newTx + "\n"
399
	first := strings.Index(content, "\n\n")
400
	mid := strings.Index(content[first+2:], "\n\n") + first + 2 // blank line between the first two transactions
401
	insertEdit := content[:mid] + newTx + content[mid:]
402
403
	b.ReportAllocs()
404
	for _, tc := range []struct{ name, edited string }{
405
		{"append", appendEdit},
406
		{"insert", insertEdit},
407
	} {
408
		ev := partialChangeFromDiff(content, tc.edited)[0].(*protocol.TextDocumentContentChangePartial)
409
		start, oldEnd, newEnd, delta := editRegion(content, ev)
410
		j := parseJournalStr(tc.edited)
411
412
		b.Run(tc.name+"/full", func(b *testing.B) {
413
			for b.Loop() {
414
				_ = tokenizeForSemantics(tc.edited, j)
415
			}
416
		})
417
		b.Run(tc.name+"/incremental", func(b *testing.B) {
418
			for b.Loop() {
419
				if _, ok := incrementalTokens(tc.edited, j, entries, semEdit{start: start, oldEnd: oldEnd, newEnd: newEnd, deltaLine: delta}); !ok {
420
					b.Fatal("fell back to full")
421
				}
422
			}
423
		})
424
	}
425
}
426
427
func applySemEdits(data []uint32, edits []protocol.SemanticTokensEdit) []uint32 {
428
	out := slices.Clone(data)
429
	for _, e := range edits {
430
		out = append(append(out[:e.Start], e.Data...), out[e.Start+e.DeleteCount:]...)
431
	}
432
	return out
433
}
434
435
func partialChangeFromDiff(from, to string) []protocol.TextDocumentContentChangeEvent {
436
	p := 0
437
	for p < len(from) && p < len(to) && from[p] == to[p] {
438
		p++
439
	}
440
	s := 0
441
	for s < len(from)-p && s < len(to)-p && from[len(from)-1-s] == to[len(to)-1-s] {
442
		s++
443
	}
444
	return []protocol.TextDocumentContentChangeEvent{
445
		&protocol.TextDocumentContentChangePartial{
446
			Range: protocol.Range{
447
				Start: lsputil.Position(from, p),
448
				End:   lsputil.Position(from, len(from)-s),
449
			},
450
			Text: to[p : len(to)-s],
451
		},
452
	}
453
}
454
455
func editRegion(from string, ev *protocol.TextDocumentContentChangePartial) (start, oldEnd, newEnd, deltaLine int) {
456
	li := lsputil.NewLineIndex(from)
457
	start = li.Offset(int(ev.Range.Start.Line), int(ev.Range.Start.Character))
458
	oldEnd = li.Offset(int(ev.Range.End.Line), int(ev.Range.End.Character))
459
	newEnd = start + len(ev.Text)
460
	deltaLine = strings.Count(ev.Text, "\n") - strings.Count(from[start:oldEnd], "\n")
461
	return
462
}
463
464
func serverSemTokensAfterEdit(t *testing.T, srv *server, in, edited string) []semanticToken {
465
	t.Helper()
466
	u := uri.URI("file:///test.journal")
467
	srv.openDoc(u, in, 1, "journal")
468
	if _, ok := srv.tokensForDoc(u); !ok {
469
		t.Fatal("priming failed")
470
	}
471
	srv.updateDoc(u, 2, partialChangeFromDiff(in, edited))
472
	toks, ok := srv.tokensForDoc(u)
473
	if !ok {
474
		t.Fatal("tokensForDoc failed after edit")
475
	}
476
	return toks
477
}
478
479
func openJournal(t testing.TB, path string) string {
480
	t.Helper()
481
	src, err := os.ReadFile(path)
482
	if err != nil {
483
		t.Fatal(err)
484
	}
485
	return string(src)
486
}