all repos

clerk @ e0c2e3c

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: refactor semantic tests, 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/testutil/golden"
14
)
15
16
func TestEncodeSemTokens(t *testing.T) {
17
	tests := map[string]struct {
18
		tokens []semanticToken
19
		want   []uint32
20
	}{
21
		"nil":   {nil, nil},
22
		"empty": {[]semanticToken{}, nil},
23
		"single": {[]semanticToken{
24
			{line: 0, col: 0, length: 4, tokenType: semDate},
25
		}, []uint32{0, 0, 4, semDate, 0}},
26
		"line 0 col 0": {[]semanticToken{
27
			{line: 0, col: 0, length: 1, tokenType: semDirective},
28
		}, []uint32{0, 0, 1, semDirective, 0}},
29
		"multiple": {[]semanticToken{
30
			{line: 0, col: 0, length: 10, tokenType: semDate},
31
			{line: 0, col: 11, length: 5, tokenType: semString},
32
			{line: 1, col: 4, length: 10, tokenType: semAccount},
33
		}, []uint32{
34
			0, 0, 10, semDate, 0,
35
			0, 11, 5, semString, 0,
36
			1, 4, 10, semAccount, 0,
37
		}},
38
		// input must be sorted by line and column (rawToSemanticTokens output);
39
		// deltas would underflow otherwise
40
		"sorted": {[]semanticToken{
41
			{line: 0, col: 0, length: 10, tokenType: semDate},
42
			{line: 0, col: 32, length: 2, tokenType: semAmount},
43
			{line: 0, col: 36, length: 3, tokenType: semCommodity},
44
			{line: 1, col: 4, length: 6, tokenType: semAccount},
45
		}, []uint32{
46
			0, 0, 10, semDate, 0,
47
			0, 32, 2, semAmount, 0,
48
			0, 4, 3, semCommodity, 0,
49
			1, 4, 6, semAccount, 0,
50
		}},
51
	}
52
53
	for tname, tt := range tests {
54
		t.Run(tname, func(t *testing.T) {
55
			if got := encodeSemTokens(tt.tokens); !slices.Equal(got, tt.want) {
56
				t.Errorf("encodeSemTokens() = %v, want %v", got, tt.want)
57
			}
58
		})
59
	}
60
}
61
62
func TestSemanticTokensEdits(t *testing.T) {
63
	tests := map[string]struct{ old, new []uint32 }{
64
		"identical":      {[]uint32{1, 2, 3}, []uint32{1, 2, 3}},
65
		"both empty":     {},
66
		"empty to non":   {nil, []uint32{1, 2}},
67
		"non to empty":   {[]uint32{1, 2}, nil},
68
		"pure insert":    {[]uint32{1, 2}, []uint32{1, 2, 3, 4}},
69
		"pure delete":    {[]uint32{1, 2, 3, 4}, []uint32{1, 2}},
70
		"replace middle": {[]uint32{1, 2, 3, 4, 5}, []uint32{1, 2, 9, 4, 5}},
71
		"replace all":    {[]uint32{1, 2}, []uint32{3, 4}},
72
		"replace tail":   {[]uint32{1, 2, 3, 4, 5}, []uint32{1, 2, 3, 4, 6}},
73
	}
74
	for tname, tt := range tests {
75
		t.Run(tname, func(t *testing.T) {
76
			edits := semanticTokensEdits(tt.old, tt.new)
77
			if got := applySemEdits(tt.old, edits); !slices.Equal(got, tt.new) {
78
				t.Errorf("apply(%v, %v) = %v, want %v", tt.old, edits, got, tt.new)
79
			}
80
			for _, e := range edits {
81
				if e.Start+e.DeleteCount > uint32(len(tt.old)) {
82
					t.Errorf("edit %+v out of bounds for old length %d", e, len(tt.old))
83
				}
84
			}
85
		})
86
	}
87
}
88
89
func TestServer_Semantic_EmptyDocument(t *testing.T) {
90
	srv := NewServer("test")
91
	srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal")
92
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
93
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")},
94
	})
95
	if err != nil {
96
		t.Fatal(err)
97
	}
98
	if len(result.Data) != 0 {
99
		t.Errorf("expected empty data for empty doc, got %d values", len(result.Data))
100
	}
101
}
102
103
func TestServer_Semantic_DocumentNotFound(t *testing.T) {
104
	result, err := NewServer("test").server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
105
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")},
106
	})
107
	if err != nil {
108
		t.Fatal(err)
109
	}
110
	if result == nil {
111
		t.Fatal("result is nil")
112
	}
113
	if result.Data != nil {
114
		t.Errorf("expected nil Data for unknown doc, got %v", result.Data)
115
	}
116
}
117
118
func TestGolden_SemanticTokens(t *testing.T) {
119
	for _, tt := range []string{"semantic-empty", "semantic-journal", "semantic-directives", "semantic-unparseable", "semantic-with-errors"} {
120
		ar := golden.Read(t, tt)
121
122
		t.Run(tt+"_golden", func(t *testing.T) {
123
			toks := renderSemanticTokens(tokSem(ar.Get("in.journal")))
124
			golden.Assert(t, ar, toks)
125
		})
126
127
		t.Run(tt+"_no-overlap", func(t *testing.T) {
128
			assertGoldenNoOverlap(t, tt, ar)
129
		})
130
	}
131
}
132
133
func TestGolden_SemanticTokensRange(t *testing.T) {
134
	ar := golden.Read(t, "semantic-range")
135
	in := ar.Get("in.journal")
136
137
	t.Run("no-overlap", func(t *testing.T) {
138
		assertGoldenNoOverlap(t, "semantic-range", ar)
139
	})
140
141
	t.Run("golden", func(t *testing.T) {
142
		u := uri.URI("file:///test.journal")
143
		srv := NewServer("test")
144
		srv.server.openDoc(u, string(in), 1, "journal")
145
146
		var out strings.Builder
147
		for line := range strings.SplitSeq(string(ar.Get("ranges.txt")), "\n") {
148
			if line == "" {
149
				continue
150
			}
151
			var name string
152
			var start, end uint32
153
			if _, err := fmt.Sscanf(line, "%s %d %d", &name, &start, &end); err != nil {
154
				t.Fatalf("ranges.txt: %q: %v", line, err)
155
			}
156
157
			res, err := srv.server.SemanticTokensRange(t.Context(), &protocol.SemanticTokensRangeParams{
158
				TextDocument: protocol.TextDocumentIdentifier{URI: u},
159
				Range: protocol.Range{
160
					Start: protocol.Position{Line: start},
161
					End:   protocol.Position{Line: end},
162
				},
163
			})
164
			if err != nil {
165
				t.Fatal(err)
166
			}
167
			tokens := decodeSemTokens(res.Data)
168
			for _, tok := range tokens {
169
				if tok.line < start || tok.line > end {
170
					t.Fatalf("%s: token %d:%d outside range lines [%d,%d]", name, tok.line, tok.col, start, end)
171
				}
172
			}
173
			fmt.Fprintf(&out, "== %s ==\n", name)
174
			out.WriteString(renderSemanticTokens(tokens))
175
		}
176
		golden.Assert(t, ar, out.String())
177
	})
178
}
179
180
func TestGolden_SemanticTokensDelta(t *testing.T) {
181
	for _, tt := range []string{"semantic-delta-edit", "semantic-delta-nochange", "semantic-delta-stale"} {
182
		ar := golden.Read(t, tt)
183
184
		t.Run(tt+"_no-overlap", func(t *testing.T) {
185
			assertGoldenNoOverlap(t, tt, ar)
186
		})
187
188
		t.Run(tt, func(t *testing.T) {
189
			in := ar.Get("in.journal")
190
191
			u := uri.URI("file:///test.journal")
192
			srv := NewServer("test")
193
			srv.server.openDoc(u, string(in), 1, "journal")
194
195
			full, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
196
				TextDocument: protocol.TextDocumentIdentifier{URI: u},
197
			})
198
			if err != nil {
199
				t.Fatal(err)
200
			}
201
			if full.ResultID == nil || *full.ResultID == "" {
202
				t.Fatal("expected a resultId on the full result")
203
			}
204
205
			finalText := in
206
			if ed := ar.Get("edited.journal"); ed != nil {
207
				srv.server.updateDoc(u, 2, []protocol.TextDocumentContentChangeEvent{
208
					&protocol.TextDocumentContentChangeWholeDocument{Text: string(ed)},
209
				})
210
				finalText = ed
211
			}
212
			prev := *full.ResultID
213
			if p := ar.Get("prev-id"); p != nil {
214
				prev = strings.TrimSpace(string(p))
215
			}
216
217
			res, err := srv.server.SemanticTokensFullDelta(t.Context(), &protocol.SemanticTokensDeltaParams{
218
				TextDocument:     protocol.TextDocumentIdentifier{URI: u},
219
				PreviousResultID: prev,
220
			})
221
			if err != nil {
222
				t.Fatal(err)
223
			}
224
225
			var client []uint32
226
			var out strings.Builder
227
			switch r := res.(type) {
228
			case *protocol.SemanticTokensDelta:
229
				fmt.Fprintf(&out, "delta resultId %s\n", *r.ResultID)
230
				client = applySemEdits(full.Data, r.Edits)
231
			case *protocol.SemanticTokens:
232
				fmt.Fprintf(&out, "full resultId %s\n", *r.ResultID)
233
				client = r.Data
234
			default:
235
				t.Fatalf("unexpected result type %T", res)
236
			}
237
238
			// The client's token state must equal the final text's tokens: an
239
			// independent reference that keeps the golden from capturing bugs.
240
			if want := encodeSemTokens(tokSem(finalText)); !slices.Equal(client, want) {
241
				t.Errorf("delta flow produced %d elems, want %d", len(client), len(want))
242
			}
243
244
			out.WriteString(renderSemanticTokens(tokSem(finalText)))
245
			golden.Assert(t, ar, out.String())
246
		})
247
	}
248
}
249
250
func assertGoldenNoOverlap(t *testing.T, tt string, ar *golden.Archive) {
251
	t.Helper()
252
	for _, f := range ar.Files {
253
		if strings.HasSuffix(f.Name, ".journal") {
254
			assertNoOverlap(t, tt, tokSem(f.Data))
255
		}
256
	}
257
}
258
259
func assertNoOverlap(t *testing.T, tt string, toks []semanticToken) {
260
	t.Helper()
261
	slices.SortFunc(toks, func(a, b semanticToken) int {
262
		if a.line != b.line {
263
			return int(a.line) - int(b.line)
264
		}
265
		return int(a.col) - int(b.col)
266
	})
267
	for i := 1; i < len(toks); i++ {
268
		prev, cur := toks[i-1], toks[i]
269
		if prev.line != cur.line {
270
			continue
271
		}
272
		if cur.col < prev.col+prev.length {
273
			t.Errorf("%s: overlapping tokens on line %d: %s@%d+%d then %s@%d+%d",
274
				tt, prev.line, tokenTypeStrings[prev.tokenType], prev.col, prev.length,
275
				tokenTypeStrings[cur.tokenType], cur.col, cur.length)
276
		}
277
	}
278
}
279
280
func renderSemanticTokens(tokens []semanticToken) string {
281
	slices.SortFunc(tokens, func(a, b semanticToken) int {
282
		if a.line != b.line {
283
			return int(a.line) - int(b.line)
284
		}
285
		return int(a.col) - int(b.col)
286
	})
287
	var b strings.Builder
288
	for _, tok := range tokens {
289
		fmt.Fprintf(&b, "%d:%d+%d %s", tok.line, tok.col, tok.length, tokenTypeStrings[tok.tokenType])
290
		for i, m := range modifierStrings {
291
			if tok.modifiers&(1<<uint(i)) != 0 {
292
				b.WriteByte(' ')
293
				b.WriteString(m)
294
			}
295
		}
296
		b.WriteByte('\n')
297
	}
298
	return b.String()
299
}
300
301
func decodeSemTokens(data []uint32) []semanticToken {
302
	var out []semanticToken
303
	line, col := 0, 0
304
	for i := 0; i+4 < len(data); i += 5 {
305
		if data[i] > 0 {
306
			line += int(data[i])
307
			col = int(data[i+1])
308
		} else {
309
			col += int(data[i+1])
310
		}
311
		out = append(out, semanticToken{
312
			line:      uint32(line),
313
			col:       uint32(col),
314
			length:    data[i+2],
315
			tokenType: data[i+3],
316
			modifiers: data[i+4],
317
		})
318
	}
319
	return out
320
}
321
322
func tokSem(content []byte) []semanticToken {
323
	c := string(content)
324
	return tokenizeForSemantics(c, parseJournalStr(c))
325
}
326
327
func BenchmarkSemanticTokens(b *testing.B) {
328
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
329
330
	// Cold path: each iteration re-parses and re-encodes, as after an edit.
331
	b.ReportAllocs()
332
	b.ResetTimer()
333
	for b.Loop() {
334
		tokens := tokenizeForSemantics(content, parseJournalStr(content))
335
		_ = encodeSemTokens(tokens)
336
	}
337
}
338
339
// BenchmarkSemanticTokensDelta measures the cost of one dela response after an edit.
340
func BenchmarkSemanticTokensDelta(b *testing.B) {
341
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
342
	prev := encodeSemTokens(tokenizeForSemantics(content, parseJournalStr(content)))
343
	edited := content + "\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n"
344
345
	b.ReportAllocs()
346
	b.ResetTimer()
347
	for b.Loop() {
348
		data := encodeSemTokens(tokenizeForSemantics(edited, parseJournalStr(edited)))
349
		_ = semanticTokensEdits(prev, data)
350
	}
351
}
352
353
func BenchmarkSemanticTokensEdits(b *testing.B) {
354
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
355
	old := encodeSemTokens(tokenizeForSemantics(content, parseJournalStr(content)))
356
	new := encodeSemTokens(tokenizeForSemantics(
357
		content+"\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n",
358
		parseJournalStr(content+"\n2000-06-15 transaction 2501\n  expenses:new  1 C\n  assets:cash\n"),
359
	))
360
361
	b.ReportAllocs()
362
	b.ResetTimer()
363
	for b.Loop() {
364
		_ = semanticTokensEdits(old, new)
365
	}
366
}
367
368
func applySemEdits(data []uint32, edits []protocol.SemanticTokensEdit) []uint32 {
369
	out := slices.Clone(data)
370
	for _, e := range edits {
371
		out = append(append(out[:e.Start], e.Data...), out[e.Start+e.DeleteCount:]...)
372
	}
373
	return out
374
}
375
376
func openJournal(t testing.TB, path string) string {
377
	t.Helper()
378
	src, err := os.ReadFile(path)
379
	if err != nil {
380
		t.Fatal(err)
381
	}
382
	return string(src)
383
}