all repos

clerk @ 5c5108b

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: semantic highlights, 2 months ago
1
package lsp
2
3
import (
4
	"context"
5
	"fmt"
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 TestSemanticTokens_Legend(t *testing.T) {
17
	if len(tokenTypeStrings) != semTypeCount {
18
		t.Fatalf("tokenTypeStrings has %d entries, want %d (one per TokenType constant)",
19
			len(tokenTypeStrings), semTypeCount)
20
	}
21
	legend := getSemanticTokensLegend()
22
	if !slices.Equal(legend.TokenTypes, tokenTypeStrings) {
23
		t.Errorf("legend.TokenTypes = %v, want %v", legend.TokenTypes, tokenTypeStrings)
24
	}
25
	if !slices.Equal(legend.TokenModifiers, modifierStrings) {
26
		t.Errorf("legend.TokenModifiers = %v, want %v", legend.TokenModifiers, modifierStrings)
27
	}
28
}
29
30
func TestSemanticTokensEncode(t *testing.T) {
31
	tests := []struct {
32
		name   string
33
		tokens []semanticToken
34
		want   []uint32
35
	}{
36
		{"nil", nil, nil},
37
		{"empty", []semanticToken{}, nil},
38
		{"single", []semanticToken{
39
			{line: 0, col: 0, length: 4, tokenType: SemanticDate},
40
		}, []uint32{0, 0, 4, SemanticDate, 0}},
41
		{"line 0 col 0", []semanticToken{
42
			{line: 0, col: 0, length: 1, tokenType: SemanticDirective},
43
		}, []uint32{0, 0, 1, SemanticDirective, 0}},
44
		{"multiple", []semanticToken{
45
			{line: 0, col: 0, length: 10, tokenType: SemanticDate},
46
			{line: 0, col: 11, length: 5, tokenType: SemString},
47
			{line: 1, col: 4, length: 10, tokenType: SemanticAccount},
48
		}, []uint32{
49
			0, 0, 10, SemanticDate, 0,
50
			0, 11, 5, SemString, 0,
51
			1, 4, 10, SemanticAccount, 0,
52
		}},
53
		// the wire format requires non-negative deltas; unsorted input must
54
		// be sorted first (commodity at col 36 comes after amount at col 32)
55
		{"unsorted input", []semanticToken{
56
			{line: 0, col: 0, length: 10, tokenType: SemanticDate},
57
			{line: 0, col: 36, length: 3, tokenType: SemanticCommodity},
58
			{line: 0, col: 32, length: 2, tokenType: SemanticAmount},
59
			{line: 1, col: 4, length: 6, tokenType: SemanticAccount},
60
		}, []uint32{
61
			0, 0, 10, SemanticDate, 0,
62
			0, 32, 2, SemanticAmount, 0,
63
			0, 4, 3, SemanticCommodity, 0,
64
			1, 4, 6, SemanticAccount, 0,
65
		}},
66
	}
67
	for _, tt := range tests {
68
		t.Run(tt.name, func(t *testing.T) {
69
			if got := encodeSemTokens(tt.tokens); !slices.Equal(got, tt.want) {
70
				t.Errorf("encodeSemTokens() = %v, want %v", got, tt.want)
71
			}
72
		})
73
	}
74
}
75
76
func TestSemanticTokens_Server_SimpleTransaction(t *testing.T) {
77
	content := "2024-01-15 test\n    expenses:food  $50\n    assets:cash\n"
78
79
	srv := NewServer("test")
80
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
81
82
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
83
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
84
	})
85
	if err != nil {
86
		t.Fatal(err)
87
	}
88
	if result == nil {
89
		t.Fatal("result is nil")
90
	}
91
	if len(result.Data) == 0 {
92
		t.Fatal("expected non-empty token data")
93
	}
94
	if len(result.Data)%5 != 0 {
95
		t.Fatalf("token data length %d is not a multiple of 5", len(result.Data))
96
	}
97
	// first token is the transaction date at line 0, col 0: deltas are 0, 0
98
	if result.Data[0] != 0 || result.Data[1] != 0 {
99
		t.Errorf("first token deltas = %d,%d, want 0,0", result.Data[0], result.Data[1])
100
	}
101
}
102
103
func TestSemanticTokens_Server_EmptyDocument(t *testing.T) {
104
	srv := NewServer("test")
105
	srv.server.openDoc(uri.URI("file:///empty.journal"), "", 1, "journal")
106
107
	result, err := srv.server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
108
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///empty.journal")},
109
	})
110
	if err != nil {
111
		t.Fatal(err)
112
	}
113
	if len(result.Data) != 0 {
114
		t.Errorf("expected empty data for empty doc, got %d values", len(result.Data))
115
	}
116
}
117
118
func TestSemanticTokens_Server_DocumentNotFound(t *testing.T) {
119
	result, err := NewServer("test").server.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{
120
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///unknown.journal")},
121
	})
122
	if err != nil {
123
		t.Fatal(err)
124
	}
125
	if result == nil {
126
		t.Fatal("result is nil")
127
	}
128
	if result.Data != nil {
129
		t.Errorf("expected nil Data for unknown doc, got %v", result.Data)
130
	}
131
}
132
133
func TestSemanticTokens_Server_Range(t *testing.T) {
134
	content := "2024-01-15 test\n    expenses:food  $50\n2024-01-16 other\n    expenses:drinks  $20\n"
135
	srv := NewServer("test")
136
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
137
138
	params := &protocol.SemanticTokensRangeParams{
139
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
140
		Range: protocol.Range{
141
			Start: protocol.Position{Line: 0, Character: 0},
142
			End:   protocol.Position{Line: 1, Character: 50},
143
		},
144
	}
145
	result, err := srv.server.SemanticTokensRange(context.Background(), params)
146
	if err != nil {
147
		t.Fatal(err)
148
	}
149
	if result == nil || len(result.Data) == 0 {
150
		t.Fatal("expected non-empty tokens for range")
151
	}
152
153
	// decode and assert every token is inside the requested line range
154
	line, col := 0, 0
155
	for i := 0; i+4 < len(result.Data); i += 5 {
156
		dl, dc := result.Data[i], result.Data[i+1]
157
		if dl > 0 {
158
			col = 0
159
		}
160
		line += int(dl)
161
		col += int(dc)
162
		if line > 1 {
163
			t.Fatalf("token at line %d outside requested range [0,1]", line)
164
		}
165
	}
166
}
167
168
// Golden
169
170
func TestSemanticTokensTxtar(t *testing.T) {
171
	tests := []string{
172
		"semantic-empty",
173
		"semantic-journal",
174
		"semantic-directives",
175
		"semantic-unparseable",
176
	}
177
178
	for _, tt := range tests {
179
		ar := golden.Read(t, tt)
180
181
		t.Run(tt+"_golden", func(t *testing.T) {
182
			toks := renderSemanticTokens(tokSem(string(ar.Get("in.journal"))))
183
			golden.Assert(t, ar, toks)
184
		})
185
186
		t.Run(tt+"_no-overlap", func(t *testing.T) {
187
			toks := tokSem(string(ar.Get("in.journal")))
188
			slices.SortFunc(toks, func(a, b semanticToken) int {
189
				if a.line != b.line {
190
					return int(a.line) - int(b.line)
191
				}
192
				return int(a.col) - int(b.col)
193
			})
194
195
			for i := 1; i < len(toks); i++ {
196
				prev, cur := toks[i-1], toks[i]
197
				if prev.line != cur.line {
198
					continue
199
				}
200
				if cur.col < prev.col+prev.length {
201
					t.Errorf("%s: overlapping tokens on line %d: %s@%d+%d then %s@%d+%d",
202
						tt, prev.line, tokenTypeStrings[prev.tokenType], prev.col, prev.length,
203
						tokenTypeStrings[cur.tokenType], cur.col, cur.length)
204
				}
205
			}
206
		})
207
	}
208
}
209
210
func renderSemanticTokens(tokens []semanticToken) string {
211
	slices.SortFunc(tokens, func(a, b semanticToken) int {
212
		if a.line != b.line {
213
			return int(a.line) - int(b.line)
214
		}
215
		return int(a.col) - int(b.col)
216
	})
217
	var b strings.Builder
218
	for _, tok := range tokens {
219
		fmt.Fprintf(&b, "%d:%d+%d %s", tok.line, tok.col, tok.length, tokenTypeStrings[tok.tokenType])
220
		for i, m := range modifierStrings {
221
			if tok.modifiers&(1<<uint(i)) != 0 {
222
				b.WriteByte(' ')
223
				b.WriteString(m)
224
			}
225
		}
226
		b.WriteByte('\n')
227
	}
228
	return b.String()
229
}
230
231
func tokSem(content string) []semanticToken {
232
	return tokenizeForSemantics(content, parseJournalStr(content))
233
}