all repos

clerk @ a91ce69

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: completion, 2 months ago
1
package lsp
2
3
import (
4
	"fmt"
5
	"strings"
6
	"testing"
7
	"time"
8
9
	"go.lsp.dev/protocol"
10
	"go.lsp.dev/uri"
11
12
	"olexsmir.xyz/clerk/internal/analyzer"
13
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
14
	"olexsmir.xyz/clerk/internal/testutil/golden"
15
	"olexsmir.xyz/clerk/journal"
16
)
17
18
func TestDetectCompletionCtx(t *testing.T) {
19
	tests := []struct {
20
		name string
21
		ctx  cmplCtx
22
		in   string
23
		want string
24
	}{
25
		{"posting account", cmplAccount, "    expenses:f^ood  $50\n", "expenses:f"},
26
		{"posting account empty", cmplAccount, "    ^\n", ""},
27
		{"posting account after colon", cmplAccount, "    expenses:^food  $50\n", "expenses:"},
28
		{"posting amount commodity", cmplCommodity, "    expenses:food  $^50\n", "$"},
29
		{"posting empty amount region", cmplCommodity, "    expenses:food  ^\n", ""},
30
		{"posting commodity word", cmplCommodity, "    expenses:food  U^SD\n", "U"},
31
		{"posting amount number", cmplNone, "    expenses:food  $5^0\n", ""},
32
		{"posting cost quantity", cmplNone, "    expenses:food  $50 @^ 1.5\n", ""},
33
		{"posting status", cmplAccount, "    * expenses:f^ood  $50\n", "expenses:f"},
34
		{"posting virtual", cmplAccount, "    (expenses:f^ood)  $50\n", "expenses:f"},
35
		{"posting comment tag", cmplTagName, "    expenses:food  ; clie^nt:x\n", "clie"},
36
		{"posting comment tag value", cmplNone, "    expenses:food  ; client:^x\n", ""},
37
		{"header payee", cmplPayee, "2024-01-15 acm^e\n    assets:cash\n", "acm"},
38
		{"header payee empty", cmplPayee, "2024-01-15 ^\n", ""},
39
		{"header payee right after date", cmplPayee, "2024-01-15^\n", ""},
40
		{"header status and code", cmplPayee, "2024-01-15 * (123) gro^cer\n", "gro"},
41
		{"header second date", cmplPayee, "2024-01-15=2024-01-16 acm^e\n", "acm"},
42
		{"header quoted payee", cmplPayee, "2024-01-15 \"ac^me\"\n", "ac"},
43
		{"header pipe note", cmplNone, "2024-01-15 acme | no^te\n", ""},
44
		{"header pipe inline", cmplNone, "2024-01-15 acme|note x^y\n", ""},
45
		{"header inline comment", cmplTagName, "2024-01-15 ; foo^", "foo"},
46
		{"directive keyword partial", cmplDirective, "acc^ount expenses\n", "acc"},
47
		{"directive keyword empty line", cmplDirective, "\n^", ""},
48
		{"account directive value", cmplAccount, "account exp^enses\n", "exp"},
49
		{"commodity directive value", cmplCommodity, "commodity U^SD\n", "U"},
50
		{"payee directive value", cmplPayee, "payee ac^me\n", "ac"},
51
		{"tag directive value", cmplTagName, "tag pro^ject\n", "pro"},
52
		{"comment tag", cmplTagName, "; clie^nt:x\n", "clie"},
53
		{"comment value", cmplNone, "; client:x^yz\n", ""},
54
		{"comment plain text", cmplTagName, "; groc^eries\n", "groc"},
55
		{"subdirective ignored", cmplNone, "account expenses\n    no^te ignore\n", ""},
56
		{"periodic header", cmplNone, "~ monthly^ budget\n", ""},
57
		{"cjk posting", cmplAccount, "    支出:食^物  50\n", "支出:食"},
58
		{"ukrainian payee", cmplPayee, "2024-01-15 прод^укти\n", "прод"},
59
		{"crlf posting", cmplAccount, "2024-01-15 x\r\n    expenses:f^ood  $50\r\n", "expenses:f"},
60
		{"crlf header", cmplPayee, "2024-01-15 acm^e\r\n    assets:cash\r\n", "acm"},
61
	}
62
	for _, tt := range tests {
63
		t.Run(tt.name, func(t *testing.T) {
64
			cont := tt.in
65
			i := strings.Index(cont, "^")
66
			if i < 0 {
67
				t.Fatal("no cursor marker '^' in content")
68
			}
69
70
			cont = cont[:i] + cont[i+1:]
71
			ctx, start := detectCompletionCtx(cont, i)
72
			prefix := cont[start:i]
73
74
			if ctx != tt.ctx {
75
				t.Errorf("ctx = %v, want %v", ctx, tt.ctx)
76
			}
77
			if prefix != tt.want {
78
				t.Errorf("prefix = %q, want %q", prefix, tt.want)
79
			}
80
		})
81
	}
82
}
83
84
func (c cmplCtx) String() string {
85
	switch c {
86
	case cmplNone:
87
		return "none"
88
	case cmplAccount:
89
		return "account"
90
	case cmplPayee:
91
		return "payee"
92
	case cmplCommodity:
93
		return "commodity"
94
	case cmplTagName:
95
		return "tag"
96
	case cmplDirective:
97
		return "directive"
98
	default:
99
		return "?"
100
	}
101
}
102
103
func TestDetectCompletion_NoJournal(t *testing.T) {
104
	// detection must not depend on a parse
105
	i := strings.Index("    no^te foo\n", "^")
106
	content := "    note foo\n"
107
	ctx, _ := detectCompletionCtx(content, i)
108
	if ctx != cmplAccount {
109
		t.Errorf("ctx = %v, want account", ctx)
110
	}
111
}
112
113
func TestDetectCompletion_Subdirective(t *testing.T) {
114
	// the lexical rule suppresses completion on subdirective lines without a parse
115
	i := strings.Index("account expenses\n    no^te ignore\n", "^")
116
	content := "account expenses\n    note ignore\n"
117
	if ctx, _ := detectCompletionCtx(content, i); ctx != cmplNone {
118
		t.Errorf("subdirective: ctx = %v, want none", ctx)
119
	}
120
	// a posting line after a transaction header is still an account context
121
	i = strings.Index("2024-01-15 acme\n    expe^nses:food  $50\n", "^")
122
	content = "2024-01-15 acme\n    expenses:food  $50\n"
123
	if ctx, _ := detectCompletionCtx(content, i); ctx != cmplAccount {
124
		t.Errorf("posting: ctx = %v, want account", ctx)
125
	}
126
	// a blank line ends the directive body; a whitespace-only line does not
127
	i = strings.Index("account expenses\n    note: x\n\n    no^te\n", "^")
128
	content = "account expenses\n    note: x\n\n    note\n"
129
	if ctx, _ := detectCompletionCtx(content, i); ctx != cmplAccount {
130
		t.Errorf("after blank line: ctx = %v, want account", ctx)
131
	}
132
	i = strings.Index("account expenses\n    note: x\n   \n    no^te\n", "^")
133
	content = "account expenses\n    note: x\n   \n    note\n"
134
	if ctx, _ := detectCompletionCtx(content, i); ctx != cmplNone {
135
		t.Errorf("whitespace-only line keeps body: ctx = %v, want none", ctx)
136
	}
137
}
138
139
func TestCompleteItems_NoTransactions(t *testing.T) {
140
	// directives-only doc: empty a.Dates must not panic the ranking
141
	content := "account expenses:food\n\n^"
142
	i := strings.Index(content, "^")
143
	content = content[:i]
144
	a := analyzer.Build(journal.NewLoader().ResolveBytes("", []byte(content)))
145
	if len(a.Dates) != 0 {
146
		t.Fatalf("setup: want 0 dates, got %d", len(a.Dates))
147
	}
148
	items := cmplItems(a, cmplAccount, content, len(content), len(content))
149
	if len(items) == 0 {
150
		t.Fatal("expected the directive-defined account to complete")
151
	}
152
	if items[0].Label != "expenses:food" {
153
		t.Errorf("label = %q, want expenses:food", items[0].Label)
154
	}
155
}
156
157
// Golden
158
159
func TestCompletionTxtar(t *testing.T) {
160
	tests := []string{
161
		"completion-journal",
162
		"completion-unicode",
163
		"completion-crlf",
164
	}
165
166
	for _, tt := range tests {
167
		ar := golden.Read(t, tt)
168
169
		t.Run(tt, func(t *testing.T) {
170
			content := string(ar.Get("in.journal"))
171
172
			var cursors []int
173
			for {
174
				m := strings.Index(content, "^")
175
				if m < 0 {
176
					break
177
				}
178
				cursors = append(cursors, m)
179
				content = content[:m] + content[m+1:]
180
			}
181
			if len(cursors) == 0 {
182
				t.Fatal("no '^' markers in in.journal")
183
			}
184
185
			srv := NewServer("test")
186
			srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
187
			srv.server.current = analyzer.Build(srv.server.loader.ResolveBytes("", []byte(content)))
188
189
			var b strings.Builder
190
			for _, c := range cursors {
191
				line, col := lsputil.LineCol(content, c)
192
				res, err := srv.server.Completion(t.Context(), &protocol.CompletionParams{
193
					TextDocumentPositionParams: protocol.TextDocumentPositionParams{
194
						TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
195
						Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},
196
					},
197
				})
198
				if err != nil {
199
					t.Fatal(err)
200
				}
201
				ctx, start := detectCompletionCtx(content, c)
202
				fmt.Fprintf(&b, "%d:%d %s %q\n", line, col, ctx, content[start:c])
203
				list, ok := res.(*protocol.CompletionList)
204
				if !ok {
205
					t.Fatalf("Completion returned %T, want *protocol.CompletionList", res)
206
				}
207
				for _, item := range list.Items {
208
					fmt.Fprintf(&b, "  %s\n", item.Label)
209
				}
210
			}
211
			golden.Assert(t, ar, b.String())
212
		})
213
	}
214
}
215
216
// Benchmark
217
218
func BenchmarkCompletion(b *testing.B) {
219
	path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal"
220
	rj, err := journal.NewLoader().Resolve(path)
221
	if err != nil {
222
		b.Fatal(err)
223
	}
224
	a := analyzer.Build(rj)
225
	content := string(rj.Occurrences[0].Src)
226
227
	srv := NewServer("test")
228
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
229
	srv.server.current = a
230
231
	for _, tc := range []struct {
232
		name string
233
		pos  int
234
	}{
235
		{"1k txns, account", strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2},
236
		{"1k txns, empty payee", strings.Index(content, "transaction 1") + len("transaction ")},
237
		{"1k txns, commodity", strings.Index(content, "2 B @@") + len("2 B")},
238
	} {
239
		b.Run(tc.name, func(b *testing.B) {
240
			line, col := lsputil.LineCol(content, tc.pos)
241
			params := &protocol.CompletionParams{
242
				TextDocumentPositionParams: protocol.TextDocumentPositionParams{
243
					TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
244
					Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},
245
				},
246
			}
247
			// warm up: first request parses the journal lazily
248
			if _, err := srv.server.Completion(b.Context(), params); err != nil {
249
				b.Fatal(err)
250
			}
251
			b.ReportAllocs()
252
			b.ResetTimer()
253
			for b.Loop() {
254
				if _, err := srv.server.Completion(b.Context(), params); err != nil {
255
					b.Fatal(err)
256
				}
257
			}
258
			// guard: line-local lexing must stay far below the old whole-file
259
			// relex (~3ms on this file); the measured target is sub-ms
260
			if avg := b.Elapsed() / time.Duration(b.N); avg > 2*time.Millisecond {
261
				b.Fatalf("completion %v/op: whole-file relex regression", avg)
262
			}
263
		})
264
	}
265
}