all repos

clerk @ 747f2da

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: config (#6), 1 month ago
1
package lsp
2
3
import (
4
	"context"
5
	"path/filepath"
6
	"slices"
7
	"strings"
8
	"sync"
9
	"testing"
10
	"time"
11
12
	"go.lsp.dev/protocol"
13
	"go.lsp.dev/uri"
14
15
	"olexsmir.xyz/clerk/internal/linter"
16
	"olexsmir.xyz/clerk/internal/testutil"
17
)
18
19
func TestAnalysisFor_CachedAndRebuilt(t *testing.T) {
20
	u := uri.File(filepath.Join(t.TempDir(), "a.journal"))
21
	srv := newServer(t)
22
	srv.server.openDoc(u, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")
23
24
	a1 := srv.server.analysisFor(u)
25
	if a1 == nil {
26
		t.Fatal("analysisFor returned nil")
27
	}
28
	if a2 := srv.server.analysisFor(u); a2 != a1 {
29
		t.Error("cached analysis not reused")
30
	}
31
32
	srv.server.updateDoc(u, 2, []protocol.TextDocumentContentChangeEvent{
33
		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-02 t\n    expenses:travel  $20\n    assets:cash\n"},
34
	})
35
	a3 := srv.server.analysisFor(u)
36
	if a3 == a1 {
37
		t.Error("edit did not rebuild the analysis")
38
	}
39
	if !slices.Contains(a3.AccountNames, "expenses:travel") || slices.Contains(a3.AccountNames, "expenses:food") {
40
		t.Errorf("stale accounts after edit: %v", a3.AccountNames)
41
	}
42
}
43
44
func TestAnalysisFor_DependentDirty(t *testing.T) {
45
	dir := t.TempDir()
46
	base := filepath.Join(dir, "base.journal")
47
	main := filepath.Join(dir, "main.journal")
48
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:cash\n"))
49
	testutil.WriteFile(t, main, []byte("include base.journal\n"))
50
51
	srv := newServer(t)
52
	uMain, uBase := uri.File(main), uri.File(base)
53
	srv.server.openDoc(uMain, "include base.journal\n", 1, "journal")
54
	srv.server.openDoc(uBase, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")
55
56
	aMain := srv.server.analysisFor(uMain)
57
	if !slices.Contains(aMain.AccountNames, "assets:cash") {
58
		t.Fatalf("main analysis missing included account: %v", aMain.AccountNames)
59
	}
60
61
	srv.server.updateDoc(uBase, 2, []protocol.TextDocumentContentChangeEvent{
62
		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"},
63
	})
64
	aMain2 := srv.server.analysisFor(uMain)
65
	if !slices.Contains(aMain2.AccountNames, "assets:bank") {
66
		t.Errorf("dependent analysis not rebuilt with new buffer content: %v", aMain2.AccountNames)
67
	}
68
}
69
70
func TestServer_Diagnostics(t *testing.T) {
71
	dir := t.TempDir()
72
	a := uri.File(filepath.Join(dir, "a.journal"))
73
	b := uri.File(filepath.Join(dir, "b.journal"))
74
75
	aContent := "account expenses:food\naccount assets:cash\ncommodity $\npayee test\n\n2024-01-01 * test\n    expenses:food  $10.00\n    assets:cash  $5.00\n"
76
	bContent := "include a.journal\n"
77
	testutil.WriteFile(t, a.Path(), []byte(aContent))
78
	testutil.WriteFile(t, b.Path(), []byte(bContent))
79
80
	srv := newServer(t)
81
	capture := &captureClient{}
82
	srv.server.client = capture
83
84
	open := func(u uri.URI, content string) {
85
		t.Helper()
86
		if err := srv.server.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{
87
			TextDocument: protocol.TextDocumentItem{URI: u, LanguageID: "journal", Version: 1, Text: content},
88
		}); err != nil {
89
			t.Fatalf("didOpen %s: %v", u, err)
90
		}
91
	}
92
	open(a, aContent)
93
	open(b, bContent)
94
95
	waitFor(t, "diagnostics for the unbalanced transaction", func() bool {
96
		da, _ := capture.lastDiags(a)
97
		return len(da) != 0
98
	})
99
100
	aEdited := "account expenses:food\naccount assets:cash\naccount assets:bank\ncommodity $\npayee test\n\n2024-01-01 * test\n    expenses:food  $20.00\n    assets:cash  $-10.00\n    assets:bank  $-10.00\n"
101
	if err := srv.server.DidChange(t.Context(), &protocol.DidChangeTextDocumentParams{
102
		TextDocument: protocol.VersionedTextDocumentIdentifier{
103
			TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: a},
104
			Version:                2,
105
		},
106
		ContentChanges: []protocol.TextDocumentContentChangeEvent{
107
			&protocol.TextDocumentContentChangeWholeDocument{Text: aEdited},
108
		},
109
	}); err != nil {
110
		t.Fatalf("didChange a: %v", err)
111
	}
112
113
	waitFor(t, "a diagnostics to clear after the edit", func() bool {
114
		da, _ := capture.lastDiags(a)
115
		return len(da) == 0
116
	})
117
118
	if err := srv.server.DidClose(t.Context(), &protocol.DidCloseTextDocumentParams{
119
		TextDocument: protocol.TextDocumentIdentifier{URI: a},
120
	}); err != nil {
121
		t.Fatalf("didClose a: %v", err)
122
	}
123
	if da, _ := capture.lastDiags(a); len(da) != 0 {
124
		t.Errorf("diagnostics not cleared on close: %v", da)
125
	}
126
}
127
128
func TestServer_DidChangeWatchedFiles_SkipsOpenDocuments(t *testing.T) {
129
	dir := t.TempDir()
130
	base := filepath.Join(dir, "base.journal")
131
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:cash\n"))
132
133
	srv := newServer(t)
134
	uBase := uri.File(base)
135
	srv.server.openDoc(uBase, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")
136
137
	a1 := srv.server.analysisFor(uBase)
138
139
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"))
140
	if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{
141
		Changes: []protocol.FileEvent{{URI: uBase, Type: protocol.FileChangeTypeChanged}},
142
	}); err != nil {
143
		t.Fatalf("didChangeWatchedFiles: %v", err)
144
	}
145
146
	if a2 := srv.server.analysisFor(uBase); a2 != a1 {
147
		t.Error("open document rebuilt from disk; buffer is authoritative")
148
	}
149
}
150
151
func TestServer_DidChangeWatchedFiles_DiskChangeDirtiesDependents(t *testing.T) {
152
	dir := t.TempDir()
153
	base := filepath.Join(dir, "base.journal")
154
	main := filepath.Join(dir, "main.journal")
155
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:cash\n"))
156
	testutil.WriteFile(t, main, []byte("include base.journal\n"))
157
158
	srv := newServer(t)
159
	srv.server.client = &captureClient{}
160
	uMain := uri.File(main)
161
	if err := srv.server.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{
162
		TextDocument: protocol.TextDocumentItem{URI: uMain, LanguageID: "journal", Version: 1, Text: "include base.journal\n"},
163
	}); err != nil {
164
		t.Fatalf("didOpen: %v", err)
165
	}
166
167
	a1 := srv.server.analysisFor(uMain)
168
	if !slices.Contains(a1.AccountNames, "assets:cash") {
169
		t.Fatalf("initial analysis missing included account: %v", a1.AccountNames)
170
	}
171
172
	// base changes on disk, outside the editor
173
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"))
174
	if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{
175
		Changes: []protocol.FileEvent{{URI: uri.File(base), Type: protocol.FileChangeTypeChanged}},
176
	}); err != nil {
177
		t.Fatalf("didChangeWatchedFiles: %v", err)
178
	}
179
180
	a2 := srv.server.analysisFor(uMain)
181
	if a2 == a1 {
182
		t.Error("disk change did not rebuild the dependent analysis")
183
	}
184
	if !slices.Contains(a2.AccountNames, "assets:bank") {
185
		t.Errorf("stale included content after disk change: %v", a2.AccountNames)
186
	}
187
}
188
189
func TestServer_ReportsConfigProblems(t *testing.T) {
190
	for name, tt := range map[string]struct {
191
		config, inline string
192
		typ            protocol.MessageType
193
		want           string
194
	}{
195
		"unknown setting":            {config: "bogus = 1\n", typ: protocol.MessageTypeWarning, want: `unknown setting "bogus"`},
196
		"unknown lint rule":          {config: "[lint]\nnot-a-rule = \"error\"\n", typ: protocol.MessageTypeWarning, want: `unknown lint rule "not-a-rule"`},
197
		"unparseable":                {config: "not toml [[[\n", typ: protocol.MessageTypeError, want: "toml:"},
198
		"settings unknown lint rule": {inline: `{"lint":{"unused_accountt":"off"}}`, typ: protocol.MessageTypeWarning, want: `unknown lint rule "unused_accountt"`},
199
	} {
200
		t.Run(name, func(t *testing.T) {
201
			capture := &captureClient{}
202
			var srv *server
203
			if tt.config != "" {
204
				cfgPath := filepath.Join(t.TempDir(), "clerk.toml")
205
				testutil.WriteFile(t, cfgPath, []byte(tt.config))
206
				s, err := NewServer("test", cfgPath)
207
				if err != nil {
208
					t.Fatal(err)
209
				}
210
				srv = s.server
211
				srv.client = capture
212
				if err := srv.Initialized(t.Context(), &protocol.InitializedParams{}); err != nil {
213
					t.Fatalf("initialized: %v", err)
214
				}
215
			} else {
216
				srv = newServer(t).server
217
				srv.client = capture
218
				if err := srv.DidChangeConfiguration(t.Context(), &protocol.DidChangeConfigurationParams{
219
					Settings: protocol.LSPAny(tt.inline),
220
				}); err != nil {
221
					t.Fatalf("didChangeConfiguration: %v", err)
222
				}
223
			}
224
			msgs := capture.shownMessages()
225
			if len(msgs) != 1 || msgs[0].Type != tt.typ || !strings.Contains(msgs[0].Message, tt.want) {
226
				t.Errorf("unexpected messages: %+v", msgs)
227
			}
228
		})
229
	}
230
}
231
232
func TestServer_Initialized_mergesConfigWithLSPSettings(t *testing.T) {
233
	cfgPath := filepath.Join(t.TempDir(), "clerk.toml")
234
	testutil.WriteFile(t, cfgPath, []byte("[lint]\nunbalanced-transaction = \"off\"\n"))
235
	s, err := NewServer("test", cfgPath)
236
	if err != nil {
237
		t.Fatal(err)
238
	}
239
	if _, err := s.server.Initialize(t.Context(), &protocol.InitializeParams{
240
		InitializationOptions: protocol.LSPAny(`{"lint": {"missing-payee": "warn"}}`),
241
	}); err != nil {
242
		t.Fatalf("initialize: %v", err)
243
	}
244
	if err := s.server.Initialized(t.Context(), &protocol.InitializedParams{}); err != nil {
245
		t.Fatalf("initialized: %v", err)
246
	}
247
	s.server.mu.RLock()
248
	got := s.server.settings
249
	s.server.mu.RUnlock()
250
	if !got.Linter.Rules[linter.UnbalancedTransactionID].Disabled {
251
		t.Error("file setting unbalanced-transaction=off not applied")
252
	}
253
	if rc := got.Linter.Rules[linter.MissingPayeeID]; rc.Disabled || rc.Severity != linter.SeverityWarning {
254
		t.Errorf("init option missing-payee=warn clobbered by file: %+v", rc)
255
	}
256
}
257
258
type captureClient struct {
259
	protocol.Client
260
	mu    sync.Mutex
261
	diag  []protocol.PublishDiagnosticsParams
262
	shown []protocol.ShowMessageParams
263
}
264
265
func (c *captureClient) PublishDiagnostics(_ context.Context, params *protocol.PublishDiagnosticsParams) error {
266
	c.mu.Lock()
267
	c.diag = append(c.diag, *params)
268
	c.mu.Unlock()
269
	return nil
270
}
271
272
func (c *captureClient) ShowMessage(_ context.Context, params *protocol.ShowMessageParams) error {
273
	c.mu.Lock()
274
	c.shown = append(c.shown, *params)
275
	c.mu.Unlock()
276
	return nil
277
}
278
279
func (c *captureClient) shownMessages() []protocol.ShowMessageParams {
280
	c.mu.Lock()
281
	defer c.mu.Unlock()
282
	return slices.Clone(c.shown)
283
}
284
285
func (c *captureClient) lastDiags(u uri.URI) ([]protocol.Diagnostic, bool) {
286
	c.mu.Lock()
287
	defer c.mu.Unlock()
288
	for _, v := range slices.Backward(c.diag) {
289
		if v.URI == u {
290
			return v.Diagnostics, true
291
		}
292
	}
293
	return nil, false
294
}
295
296
func waitFor(t *testing.T, what string, cond func() bool) {
297
	t.Helper()
298
	deadline := time.Now().Add(5 * time.Second)
299
	for time.Now().Before(deadline) {
300
		if cond() {
301
			return
302
		}
303
		time.Sleep(10 * time.Millisecond)
304
	}
305
	t.Fatalf("timed out waiting for %s", what)
306
}
307
308
func newServer(tb testing.TB) Server {
309
	tb.Helper()
310
	s, err := NewServer("test", filepath.Join(tb.TempDir(), "clerk.toml"))
311
	if err != nil {
312
		tb.Fatal(err)
313
	}
314
	return s
315
}