all repos

clerk @ 8dcac8b50c40fbb802c43d17d2c91e46b7087850

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
test: lsp: align test naming, benchmark warm-up, and helper typo..., 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_SimpleTransaction(t *testing.T) {
63
	content := `2024-01-15 test
64
    expenses:food  $50
65
    assets:cash
66
`
67
68
	srv := NewServer("test")
69
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
70
71
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
72
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
73
	})
74
	if err != nil {
75
		t.Fatal(err)
76
	}
77
	if result == nil {
78
		t.Fatal("result is nil")
79
	}
80
	if len(result.Data) == 0 {
81
		t.Fatal("expected non-empty token data")
82
	}
83
	if len(result.Data)%5 != 0 {
84
		t.Fatalf("token data length %d is not a multiple of 5", len(result.Data))
85
	}
86
	// first token is the transaction date at line 0, col 0: deltas are 0, 0
87
	if result.Data[0] != 0 || result.Data[1] != 0 {
88
		t.Errorf("first token deltas = %d,%d, want 0,0", result.Data[0], result.Data[1])
89
	}
90
}
91
92
func TestServer_Semantic_EmptyDocument(t *testing.T) {
93
	srv := NewServer("test")
94
	srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal")
95
96
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
97
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")},
98
	})
99
	if err != nil {
100
		t.Fatal(err)
101
	}
102
	if len(result.Data) != 0 {
103
		t.Errorf("expected empty data for empty doc, got %d values", len(result.Data))
104
	}
105
}
106
107
func TestServer_Semantic_DocumentNotFound(t *testing.T) {
108
	result, err := NewServer("test").server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
109
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")},
110
	})
111
	if err != nil {
112
		t.Fatal(err)
113
	}
114
	if result == nil {
115
		t.Fatal("result is nil")
116
	}
117
	if result.Data != nil {
118
		t.Errorf("expected nil Data for unknown doc, got %v", result.Data)
119
	}
120
}
121
122
func TestServer_Semantic_Range(t *testing.T) {
123
	content := `2024-01-15 test
124
    expenses:food  $50
125
126
2024-01-16 other
127
  expenses:drinks  $20
128
`
129
130
	srv := NewServer("test")
131
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
132
133
	result, err := srv.server.SemanticTokensRange(t.Context(), &protocol.SemanticTokensRangeParams{
134
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
135
		Range: protocol.Range{
136
			Start: protocol.Position{Line: 0, Character: 0},
137
			End:   protocol.Position{Line: 1, Character: 50},
138
		},
139
	})
140
	if err != nil {
141
		t.Fatal(err)
142
	}
143
	if result == nil || len(result.Data) == 0 {
144
		t.Fatal("expected non-empty tokens for range")
145
	}
146
147
	// decode and assert every token is inside the requested line range
148
	line, col := 0, 0
149
	for i := 0; i+4 < len(result.Data); i += 5 {
150
		dl, dc := result.Data[i], result.Data[i+1]
151
		if dl > 0 {
152
			col = 0
153
		}
154
		line += int(dl)
155
		col += int(dc)
156
		if line > 1 {
157
			t.Fatalf("token at line %d outside requested range [0,1]", line)
158
		}
159
	}
160
}
161
162
// Golden
163
164
func TestGolden_SemanticTokens(t *testing.T) {
165
	for _, tt := range []string{"semantic-empty", "semantic-journal", "semantic-directives", "semantic-unparseable", "semantic-with-errors"} {
166
		ar := golden.Read(t, tt)
167
168
		t.Run(tt+"_golden", func(t *testing.T) {
169
			toks := renderSemanticTokens(tokSem(ar.Get("in.journal")))
170
			golden.Assert(t, ar, toks)
171
		})
172
173
		t.Run(tt+"_no-overlap", func(t *testing.T) {
174
			toks := tokSem(ar.Get("in.journal"))
175
			slices.SortFunc(toks, func(a, b semanticToken) int {
176
				if a.line != b.line {
177
					return int(a.line) - int(b.line)
178
				}
179
				return int(a.col) - int(b.col)
180
			})
181
182
			for i := 1; i < len(toks); i++ {
183
				prev, cur := toks[i-1], toks[i]
184
				if prev.line != cur.line {
185
					continue
186
				}
187
				if cur.col < prev.col+prev.length {
188
					t.Errorf("%s: overlapping tokens on line %d: %s@%d+%d then %s@%d+%d",
189
						tt, prev.line, tokenTypeStrings[prev.tokenType], prev.col, prev.length,
190
						tokenTypeStrings[cur.tokenType], cur.col, cur.length)
191
				}
192
			}
193
		})
194
	}
195
}
196
197
func renderSemanticTokens(tokens []semanticToken) string {
198
	slices.SortFunc(tokens, func(a, b semanticToken) int {
199
		if a.line != b.line {
200
			return int(a.line) - int(b.line)
201
		}
202
		return int(a.col) - int(b.col)
203
	})
204
	var b strings.Builder
205
	for _, tok := range tokens {
206
		fmt.Fprintf(&b, "%d:%d+%d %s", tok.line, tok.col, tok.length, tokenTypeStrings[tok.tokenType])
207
		for i, m := range modifierStrings {
208
			if tok.modifiers&(1<<uint(i)) != 0 {
209
				b.WriteByte(' ')
210
				b.WriteString(m)
211
			}
212
		}
213
		b.WriteByte('\n')
214
	}
215
	return b.String()
216
}
217
218
func tokSem(content []byte) []semanticToken {
219
	c := string(content)
220
	return tokenizeForSemantics(c, parseJournalStr(c))
221
}
222
223
func BenchmarkSemanticTokens(b *testing.B) {
224
	content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")
225
226
	// Cold path: each iteration re-parses and re-encodes, as after an edit.
227
	b.ReportAllocs()
228
	b.ResetTimer()
229
	for b.Loop() {
230
		tokens := tokenizeForSemantics(content, parseJournalStr(content))
231
		_ = encodeSemTokens(tokens)
232
	}
233
}
234
235
func openJournal(t testing.TB, path string) string {
236
	t.Helper()
237
	src, err := os.ReadFile(path)
238
	if err != nil {
239
		t.Fatal(err)
240
	}
241
	return string(src)
242
}