all repos

clerk @ 1b9e6e1

missing tooling for ledger/hledger

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

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