all repos

clerk @ f2dd916

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: completion: replaces whole word, 29 days 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/lsp/lsputil"
13
	"olexsmir.xyz/clerk/internal/testutil/golden"
14
	"olexsmir.xyz/clerk/journal"
15
)
16
17
func TestDetectCompletion_NoJournal(t *testing.T) {
18
	// detection must not depend on a parse
19
	i := strings.Index("    no^te foo\n", "^")
20
	content := "    note foo\n"
21
	ctx, _ := detectCompletionCtx(content, i)
22
	if ctx != cmplAccount {
23
		t.Errorf("ctx = %v, want account", ctx)
24
	}
25
}
26
27
func TestDateStyle(t *testing.T) {
28
	tests := map[string]struct {
29
		history []string
30
		sep     byte
31
		hasYear bool
32
	}{
33
		"empty":    {nil, '-', true},
34
		"dash":     {[]string{"2024-01-02"}, '-', true},
35
		"slash":    {[]string{"2024/01/20"}, '/', true},
36
		"yearless": {[]string{"6-10"}, '-', false},
37
	}
38
	for name, tt := range tests {
39
		t.Run(name, func(t *testing.T) {
40
			sep, hasYear := dateStyle(tt.history)
41
			if sep != tt.sep || hasYear != tt.hasYear {
42
				t.Errorf("dateStyle = %q/%v, want %q/%v", sep, hasYear, tt.sep, tt.hasYear)
43
			}
44
		})
45
	}
46
}
47
48
func TestDateTokenEnd(t *testing.T) {
49
	tests := map[string]struct {
50
		content string
51
		start   int
52
		want    int
53
	}{
54
		"full date":            {"2024-01-02 x", 0, 10},
55
		"unpadded":             {"2024/1/2 x", 0, 8},
56
		"yearless":             {"6-10 x", 0, 4},
57
		"stops at =":           {"2024-01-02=2024-01-03", 0, 10},
58
		"stops at comment":     {"2024-01-02;x", 0, 10},
59
		"partial trailing sep": {"2024- x", 0, 5},
60
		"partial mid":          {"2024-0 x", 0, 6},
61
	}
62
	for name, tt := range tests {
63
		t.Run(name, func(t *testing.T) {
64
			if got := dateTokenEnd(tt.content, tt.start); got != tt.want {
65
				t.Errorf("dateTokenEnd = %d, want %d", got, tt.want)
66
			}
67
		})
68
	}
69
}
70
71
func TestDatePatternMatch(t *testing.T) {
72
	tests := map[string]struct {
73
		pattern, canonical string
74
		want               bool
75
	}{
76
		"digit subsequence":   {"2024-01-0", "2024-01-05", true},
77
		"same sep":            {"2024/0", "2024/01/20", true},
78
		"other sep rejected":  {"2024/0", "2024-01-20", false},
79
		"yearless":            {"6-10", "6-10", true},
80
		"yearless padded rej": {"06-10", "6-10", false},
81
		"no match":            {"2025", "2024-01-20", false},
82
		"empty":               {"", "2024-01-20", true},
83
	}
84
	for name, tt := range tests {
85
		t.Run(name, func(t *testing.T) {
86
			if got := datePatternMatch(tt.pattern, tt.canonical); got != tt.want {
87
				t.Errorf("datePatternMatch = %v, want %v", got, tt.want)
88
			}
89
		})
90
	}
91
}
92
93
func TestGolden_Completion(t *testing.T) {
94
	for _, tt := range []string{"completion-contexts", "completion-journal", "completion-no-transactions"} {
95
		t.Run(tt, func(t *testing.T) {
96
			ar := golden.Read(t, tt)
97
			h := newTxtarHarness(t, ar)
98
			if err := h.srv.applySettings(t.Context(), []byte(`{"latin_to_cyrillic_completion": true}`)); err != nil {
99
				t.Fatal(err)
100
			}
101
102
			var b strings.Builder
103
			for i, c := range h.cursors {
104
				tdp := h.textDocumentPosition(i)
105
				res, err := h.srv.Completion(t.Context(), &protocol.CompletionParams{
106
					TextDocumentPositionParams: tdp,
107
				})
108
				if err != nil {
109
					t.Fatal(err)
110
				}
111
				ctx, start := detectCompletionCtx(h.content, c)
112
				fmt.Fprintf(&b, "%d:%d %s %q\n", tdp.Position.Line, tdp.Position.Character, ctx, h.content[start:c])
113
				list, ok := res.(*protocol.CompletionList)
114
				if !ok {
115
					t.Fatalf("Completion returned %T, want *protocol.CompletionList", res)
116
				}
117
				now := time.Now()
118
				var sep byte
119
				var hasYear bool
120
				if ctx == cmplDate {
121
					if an := h.srv.analysisFor(h.uri); an != nil {
122
						sep, hasYear = dateStyle(an.DateStrings)
123
					}
124
				}
125
				for _, item := range list.Items {
126
					label := item.Label
127
					if ctx == cmplDate {
128
						switch label {
129
						case renderDate(now, sep, hasYear):
130
							label = "today"
131
						case renderDate(now.AddDate(0, 0, -1), sep, hasYear):
132
							label = "yesterday"
133
						case renderDate(now.AddDate(0, 0, -2), sep, hasYear):
134
							label = "2 days ago"
135
						}
136
					}
137
					fmt.Fprintf(&b, "  %s\n", label)
138
				}
139
			}
140
			golden.Assert(t, ar, b.String())
141
		})
142
	}
143
}
144
145
func TestCompletionEditReplacesWholeWord(t *testing.T) {
146
	// mid-word completion: accepting replaces the whole typed word, leaving no suffix
147
	cases := map[string]struct{ journal, cursorLine, item, wantLine string }{
148
		"account": {
149
			journal: `account assets:bank
150
2024-01-15 x
151
  assets:bank $10
152
  assets:cash
153
`,
154
			cursorLine: "  assets:b^nk $10\n",
155
			item:       "assets:bank",
156
			wantLine:   "  assets:bank $10\n",
157
		},
158
		"payee": {
159
			journal: `account assets:cash
160
2024-01-15 Grocery Store
161
  assets:cash
162
163
2024-01-15 Grocery
164
  assets:cash
165
`,
166
			cursorLine: "2024-01-15 Groc^ery Store\n",
167
			item:       "Grocery",
168
			wantLine:   "2024-01-15 Grocery Store\n",
169
		},
170
		"tag value": {
171
			journal: `account assets:cash
172
2024-01-15 x
173
  assets:cash
174
; client:acme
175
`,
176
			cursorLine: "; client:a^cme\n",
177
			item:       "acme",
178
			wantLine:   "; client:acme\n",
179
		},
180
	}
181
	for name, tt := range cases {
182
		t.Run(name, func(t *testing.T) {
183
			cur := strings.Index(tt.cursorLine, "^") + len(tt.journal)
184
			content := strings.Replace(tt.journal+tt.cursorLine, "^", "", 1)
185
			srv := newServer(t).server
186
			srv.openDoc(uri.URI("file:///t.journal"), content, 1, "journal")
187
			line, col := lsputil.LineCol(content, cur)
188
			res, err := srv.Completion(t.Context(), &protocol.CompletionParams{
189
				TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///t.journal")},
190
				Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},
191
			})
192
			if err != nil {
193
				t.Fatal(err)
194
			}
195
196
			list := res.(*protocol.CompletionList)
197
			var edit *protocol.TextEdit
198
			for _, it := range list.Items {
199
				if it.Label == tt.item {
200
					edit = it.TextEdit.(*protocol.TextEdit)
201
				}
202
			}
203
			if edit == nil {
204
				t.Fatalf("item %q not offered (got %v)", tt.item, labelsOf(list))
205
			}
206
207
			s := lsputil.Offset(content, int(edit.Range.Start.Line), int(edit.Range.Start.Character))
208
			e := lsputil.Offset(content, int(edit.Range.End.Line), int(edit.Range.End.Character))
209
			got := content[:s] + edit.NewText + content[e:]
210
			lines := strings.Split(got, "\n")
211
			if lines[edit.Range.Start.Line] != strings.TrimSuffix(tt.wantLine, "\n") {
212
				t.Errorf("edited line = %q, want %q", lines[edit.Range.Start.Line], strings.TrimSuffix(tt.wantLine, "\n"))
213
			}
214
		})
215
	}
216
}
217
218
func labelsOf(list *protocol.CompletionList) []string {
219
	labels := make([]string, 0, len(list.Items))
220
	for _, it := range list.Items {
221
		labels = append(labels, it.Label)
222
	}
223
	return labels
224
}
225
226
func (c cmplCtx) String() string {
227
	switch c {
228
	case cmplNone:
229
		return "none"
230
	case cmplAccount:
231
		return "account"
232
	case cmplPayee:
233
		return "payee"
234
	case cmplCommodity:
235
		return "commodity"
236
	case cmplTagName:
237
		return "tag"
238
	case cmplTagValue:
239
		return "tag-value"
240
	case cmplDate:
241
		return "date"
242
	case cmplDirective:
243
		return "directive"
244
	default:
245
		return "?"
246
	}
247
}
248
249
func BenchmarkCompletion(b *testing.B) {
250
	path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal"
251
	rj, err := journal.NewLoader().Resolve(path)
252
	if err != nil {
253
		b.Fatal(err)
254
	}
255
	content := string(rj.Occurrences[0].Src)
256
257
	srv := newServer(b)
258
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
259
	srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache
260
261
	for tname, tt := range map[string]int{
262
		"1k txns, account":        strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,
263
		"1k txns, empty payee":    strings.Index(content, "transaction 1") + len("transaction "),
264
		"1k txns, commodity":      strings.Index(content, "2 B @@") + len("2 B"),
265
		"1k txns, date":           strings.Index(content, "2000-01-0") + len("2000-01-0"),
266
		"1k txns, price date":     strings.Index(content, "P 2000-01-0") + len("P 2000-01-0"),
267
		"1k txns, price quantity": strings.Index(content, "P 2000-01-01 A  0.70") + len("P 2000-01-01 A  0.70"),
268
	} {
269
		b.Run(tname, func(b *testing.B) {
270
			line, col := lsputil.LineCol(content, tt)
271
			params := &protocol.CompletionParams{
272
				TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
273
				Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},
274
			}
275
276
			// warm up
277
			if _, err := srv.server.Completion(b.Context(), params); err != nil {
278
				b.Fatal(err)
279
			}
280
			b.ReportAllocs()
281
			b.ResetTimer()
282
			for b.Loop() {
283
				if _, err := srv.server.Completion(b.Context(), params); err != nil {
284
					b.Fatal(err)
285
				}
286
			}
287
			// guard: line-local lexing must stay far below the old whole-file
288
			// relex (~3ms on this file); the measured target is sub-ms
289
			if avg := b.Elapsed() / time.Duration(b.N); avg > 2*time.Millisecond {
290
				b.Fatalf("completion %v/op: whole-file relex regression", avg)
291
			}
292
		})
293
	}
294
}
295
296
func BenchmarkCompletionTransliteration(b *testing.B) {
297
	tails := []string{"а", "б", "в", "г", "д", "е", "є", "ж", "з", "и", "і", "ї", "й", "к", "л", "м", "н", "о", "п", "р"}
298
	var sb strings.Builder
299
	for _, n := range []string{"Витрати", "Доходи", "Активи", "Капітал", "Зобовязання"} {
300
		for _, t := range tails {
301
			fmt.Fprintf(&sb, "account %s:%s%s\n", n, n, t)
302
		}
303
	}
304
	sb.WriteString("account vyt")
305
	content := sb.String()
306
307
	srv := newServer(b)
308
	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")
309
	srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache
310
	if err := srv.server.applySettings(b.Context(), []byte(`{"latin_to_cyrillic_completion": true}`)); err != nil {
311
		b.Fatal(err)
312
	}
313
314
	line, col := lsputil.LineCol(content, len(content))
315
	params := &protocol.CompletionParams{
316
		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")},
317
		Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},
318
	}
319
320
	warm, err := srv.server.Completion(b.Context(), params)
321
	if err != nil {
322
		b.Fatal(err)
323
	}
324
325
	// Guard: the Cyrillic corpus completes only through the transliterated
326
	// pattern; without it the list collapses to the latin "vyt" placeholder.
327
	if got := len(warm.(*protocol.CompletionList).Items); got <= 1 {
328
		b.Fatalf("transliteration matching did not engage (items: %d)", got)
329
	}
330
331
	b.ReportAllocs()
332
	b.ResetTimer()
333
	for b.Loop() {
334
		if _, err := srv.server.Completion(b.Context(), params); err != nil {
335
			b.Fatal(err)
336
		}
337
	}
338
}