all repos

clerk @ f68a2c67d3b00aa4f5b556403ea30887c6eedf8c

missing tooling for ledger/hledger

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

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