all repos

clerk @ cddf442

missing tooling for ledger/hledger

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

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