all repos

clerk @ 8dcac8b50c40fbb802c43d17d2c91e46b7087850

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: cache per-document analyses with include-aware invalidation..., 1 month ago
1
package lsp
2
3
import (
4
	"context"
5
	"path/filepath"
6
	"slices"
7
	"sync"
8
	"testing"
9
	"time"
10
11
	"go.lsp.dev/protocol"
12
	"go.lsp.dev/uri"
13
14
	"olexsmir.xyz/clerk/internal/testutil"
15
)
16
17
func TestAnalysisFor_CachedAndRebuilt(t *testing.T) {
18
	u := uri.File(filepath.Join(t.TempDir(), "a.journal"))
19
	srv := NewServer("test")
20
	srv.server.openDoc(u, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")
21
22
	a1 := srv.server.analysisFor(u)
23
	if a1 == nil {
24
		t.Fatal("analysisFor returned nil")
25
	}
26
	if a2 := srv.server.analysisFor(u); a2 != a1 {
27
		t.Error("cached analysis not reused")
28
	}
29
30
	srv.server.updateDoc(u, 2, []protocol.TextDocumentContentChangeEvent{
31
		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-02 t\n    expenses:travel  $20\n    assets:cash\n"},
32
	})
33
	a3 := srv.server.analysisFor(u)
34
	if a3 == a1 {
35
		t.Error("edit did not rebuild the analysis")
36
	}
37
	if !slices.Contains(a3.AccountNames, "expenses:travel") || slices.Contains(a3.AccountNames, "expenses:food") {
38
		t.Errorf("stale accounts after edit: %v", a3.AccountNames)
39
	}
40
}
41
42
func TestAnalysisFor_DependentDirty(t *testing.T) {
43
	dir := t.TempDir()
44
	base := filepath.Join(dir, "base.journal")
45
	main := filepath.Join(dir, "main.journal")
46
	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:cash\n"))
47
	testutil.WriteFile(t, main, []byte("include base.journal\n"))
48
49
	srv := NewServer("test")
50
	uMain, uBase := uri.File(main), uri.File(base)
51
	srv.server.openDoc(uMain, "include base.journal\n", 1, "journal")
52
	srv.server.openDoc(uBase, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")
53
54
	aMain := srv.server.analysisFor(uMain)
55
	if !slices.Contains(aMain.AccountNames, "assets:cash") {
56
		t.Fatalf("main analysis missing included account: %v", aMain.AccountNames)
57
	}
58
59
	srv.server.updateDoc(uBase, 2, []protocol.TextDocumentContentChangeEvent{
60
		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"},
61
	})
62
	aMain2 := srv.server.analysisFor(uMain)
63
	if !slices.Contains(aMain2.AccountNames, "assets:bank") {
64
		t.Errorf("dependent analysis not rebuilt with new buffer content: %v", aMain2.AccountNames)
65
	}
66
}
67
68
func TestServer_Diagnostics(t *testing.T) {
69
	dir := t.TempDir()
70
	a := uri.File(filepath.Join(dir, "a.journal"))
71
	b := uri.File(filepath.Join(dir, "b.journal"))
72
73
	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"
74
	bContent := "include a.journal\n"
75
	testutil.WriteFile(t, a.Path(), []byte(aContent))
76
	testutil.WriteFile(t, b.Path(), []byte(bContent))
77
78
	srv := NewServer("test")
79
	capture := &captureClient{}
80
	srv.server.client = capture
81
82
	open := func(u uri.URI, content string) {
83
		t.Helper()
84
		if err := srv.server.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{
85
			TextDocument: protocol.TextDocumentItem{URI: u, LanguageID: "journal", Version: 1, Text: content},
86
		}); err != nil {
87
			t.Fatalf("didOpen %s: %v", u, err)
88
		}
89
	}
90
	open(a, aContent)
91
	open(b, bContent)
92
93
	waitFor(t, "diagnostics for the unbalanced transaction", func() bool {
94
		da, _ := capture.lastDiags(a)
95
		return len(da) != 0
96
	})
97
98
	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"
99
	if err := srv.server.DidChange(t.Context(), &protocol.DidChangeTextDocumentParams{
100
		TextDocument: protocol.VersionedTextDocumentIdentifier{
101
			TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: a},
102
			Version:                2,
103
		},
104
		ContentChanges: []protocol.TextDocumentContentChangeEvent{
105
			&protocol.TextDocumentContentChangeWholeDocument{Text: aEdited},
106
		},
107
	}); err != nil {
108
		t.Fatalf("didChange a: %v", err)
109
	}
110
111
	waitFor(t, "a diagnostics to clear after the edit", func() bool {
112
		da, _ := capture.lastDiags(a)
113
		return len(da) == 0
114
	})
115
116
	if err := srv.server.DidClose(t.Context(), &protocol.DidCloseTextDocumentParams{
117
		TextDocument: protocol.TextDocumentIdentifier{URI: a},
118
	}); err != nil {
119
		t.Fatalf("didClose a: %v", err)
120
	}
121
	if da, _ := capture.lastDiags(a); len(da) != 0 {
122
		t.Errorf("diagnostics not cleared on close: %v", da)
123
	}
124
}
125
126
type captureClient struct {
127
	protocol.Client
128
	mu   sync.Mutex
129
	diag []protocol.PublishDiagnosticsParams
130
}
131
132
func (c *captureClient) PublishDiagnostics(_ context.Context, params *protocol.PublishDiagnosticsParams) error {
133
	c.mu.Lock()
134
	c.diag = append(c.diag, *params)
135
	c.mu.Unlock()
136
	return nil
137
}
138
139
func (c *captureClient) lastDiags(u uri.URI) ([]protocol.Diagnostic, bool) {
140
	c.mu.Lock()
141
	defer c.mu.Unlock()
142
	for _, v := range slices.Backward(c.diag) {
143
		if v.URI == u {
144
			return v.Diagnostics, true
145
		}
146
	}
147
	return nil, false
148
}
149
150
func waitFor(t *testing.T, what string, cond func() bool) {
151
	t.Helper()
152
	deadline := time.Now().Add(5 * time.Second)
153
	for time.Now().Before(deadline) {
154
		if cond() {
155
			return
156
		}
157
		time.Sleep(10 * time.Millisecond)
158
	}
159
	t.Fatalf("timed out waiting for %s", what)
160
}