all repos

clerk @ d12c0794480a4e65333d20e93d8a9f24a53ea056

missing tooling for ledger/hledger

clerk/journal/loader_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ast: inline leaf value+span wrapper structs, 1 month ago
1
package journal
2
3
import (
4
	"fmt"
5
	"os"
6
	"path/filepath"
7
	"strings"
8
	"testing"
9
10
	"olexsmir.xyz/clerk/internal/testutil"
11
	"olexsmir.xyz/clerk/internal/testutil/golden"
12
	"olexsmir.xyz/clerk/internal/testutil/txtar"
13
	"olexsmir.xyz/clerk/journal/ast"
14
)
15
16
func TestLoader_Resolve(t *testing.T) {
17
	for _, tname := range []string{"basic", "with-include", "year-propagation", "year-context", "repeated-include", "parse-errors"} {
18
		t.Run(tname, func(t *testing.T) {
19
			a := golden.Read(t, tname)
20
			fsys, err := a.FS()
21
			if err != nil {
22
				t.Fatal(err)
23
			}
24
			rj, err := NewLoader().ResolveFS(fsys, "in.journal")
25
			if err != nil {
26
				t.Fatalf("resolving in.journal: %v", err)
27
			}
28
			golden.Assert(t, a, dumpResolved(rj))
29
		})
30
	}
31
}
32
33
func TestLoader_Resolve_cycleDetection(t *testing.T) {
34
	rj := resolveTxtar(t, "a.journal", `
35
-- a.journal --
36
include b.journal
37
38
-- b.journal --
39
include a.journal
40
`)
41
	if len(rj.FileErrors()) == 0 {
42
		t.Fatal("expected cycle error")
43
	}
44
}
45
46
func dumpResolved(rj *ResolvedJournal) string {
47
	var b strings.Builder
48
	for _, pf := range rj.Occurrences {
49
		fmt.Fprintf(&b, "== %s ==\n", pf.Path)
50
		for i, e := range pf.Ast.Entries {
51
			if s := entrySummary(e); s != "" {
52
				fmt.Fprintf(&b, "  %d %s\n", i+1, s)
53
			}
54
		}
55
		for _, fe := range pf.FileErrors {
56
			fmt.Fprintf(&b, "  error: %s\n", fe.Message)
57
		}
58
		for _, pe := range pf.Errors {
59
			fmt.Fprintf(&b, "  parse-error: %s\n", pe.Message)
60
		}
61
	}
62
	return b.String()
63
}
64
65
func entrySummary(e ast.Entry) string {
66
	switch e := e.(type) {
67
	case *ast.BlankLine:
68
		return ""
69
	case *ast.IncludeDirective:
70
		return "include " + e.Path
71
	case *ast.YearDirective:
72
		return fmt.Sprintf("year %d", e.Year)
73
	case *ast.AccountDirective:
74
		return "account " + e.Account.String()
75
	case *ast.Transaction:
76
		s := fmt.Sprintf("%04d-%02d-%02d", e.Date.Year, e.Date.Month, e.Date.Day)
77
		if e.Payee != "" {
78
			s += " " + e.Payee
79
		}
80
		return s
81
	default:
82
		return fmt.Sprintf("<%T>", e)
83
	}
84
}
85
86
func TestLoader_ContentCache(t *testing.T) {
87
	dir := t.TempDir()
88
	fpath := filepath.Join(dir, "a.journal")
89
90
	os.WriteFile(fpath, []byte("2024/01/15 t\n  a  $1\n"), 0o644)
91
	l := NewLoader()
92
93
	// first resolve populates cache.
94
	rj1 := mustResolve(t, l, fpath)
95
	date1 := txDate(rj1)
96
97
	// modify file, resolve again - should return cached date.
98
	os.WriteFile(fpath, []byte("2024/06/01 t\n  a  $1\n"), 0o644)
99
	rj2 := mustResolve(t, l, fpath)
100
	if txDate(rj2) != date1 {
101
		t.Fatal("expected cached date before invalidation")
102
	}
103
104
	// invalidate and resolve again - should see new date.
105
	l.InvalidateFile(fpath)
106
	rj3 := mustResolve(t, l, fpath)
107
	if txDate(rj3) != "2024-06-01" {
108
		t.Fatalf("expected 2024-06-01 after invalidation, got %s", txDate(rj3))
109
	}
110
}
111
112
func TestLoader_ContentCache_LineEndings(t *testing.T) {
113
	dir := t.TempDir()
114
	fpath := filepath.Join(dir, "crlf.journal")
115
	os.WriteFile(fpath, []byte("2024/01/01 t\r\n  a  $1\r\n"), 0o644)
116
117
	rj, err := NewLoader().Resolve(fpath)
118
	if err != nil || len(rj.Occurrences[0].Errors) > 0 {
119
		t.Fatal("CRLF file should parse without errors")
120
	}
121
}
122
123
func TestResolveIncludePath(t *testing.T) {
124
	tests := []struct {
125
		parent, pattern string
126
		wantPath        string
127
		wantErr         bool
128
	}{
129
		{"main.journal", "child.journal", "child.journal", false},
130
		{"main.journal", "sub/child.journal", "sub/child.journal", false},
131
		{"main.journal", "../other.journal", "", true},
132
		{"sub/main.journal", "child.journal", "sub/child.journal", false},
133
		{"sub/main.journal", "../other.journal", "", true},
134
	}
135
	for _, tt := range tests {
136
		got, err := resolveIncludePath(tt.parent, tt.pattern)
137
		if tt.wantErr {
138
			if err == nil {
139
				t.Errorf("resolveIncludePath(%q, %q): want error, got %q", tt.parent, tt.pattern, got)
140
			}
141
			continue
142
		}
143
		if err != nil || got != tt.wantPath {
144
			t.Errorf("resolveIncludePath(%q, %q) = %q, %v; want %q", tt.parent, tt.pattern, got, err, tt.wantPath)
145
		}
146
	}
147
}
148
149
// helpers
150
151
func resolveTxtar(t *testing.T, rootFile, archive string) *ResolvedJournal {
152
	t.Helper()
153
	a := txtar.Parse([]byte(archive))
154
	fsys, err := txtar.FS(a)
155
	if err != nil {
156
		t.Fatal(err)
157
	}
158
	l := NewLoader()
159
	rj, err := l.ResolveFS(fsys, rootFile)
160
	if err != nil {
161
		t.Fatal(err)
162
	}
163
	return rj
164
}
165
166
func mustResolve(t *testing.T, l *Loader, fpath string) *ResolvedJournal {
167
	t.Helper()
168
	rj, err := l.Resolve(fpath)
169
	if err != nil {
170
		t.Fatal(err)
171
	}
172
	return rj
173
}
174
175
func txDate(rj *ResolvedJournal) string {
176
	tx := rj.Occurrences[0].Ast.Entries[0].(*ast.Transaction)
177
	return fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)
178
}
179
180
// writeFiles writes the given files into dir.
181
func writeFiles(t *testing.T, dir string, files map[string]string) {
182
	t.Helper()
183
	for name, content := range files {
184
		testutil.WriteFile(t, filepath.Join(dir, name), []byte(content))
185
	}
186
}
187
188
// newLoaderDir writes files into a temp dir and returns a loader over it.
189
func newLoaderDir(t *testing.T, files map[string]string) (*Loader, string) {
190
	t.Helper()
191
	dir := t.TempDir()
192
	writeFiles(t, dir, files)
193
	return NewLoader(), dir
194
}
195
196
// mustOccurrence returns the occurrence of path in the resolve.
197
func mustOccurrence(t *testing.T, rj *ResolvedJournal, path string) *ParsedFile {
198
	t.Helper()
199
	got := rj.ByPath[path]
200
	if len(got) == 0 {
201
		t.Fatalf("file not resolved: %s", path)
202
	}
203
	return got[0]
204
}
205
206
// TestLoader_ParseCache_SharedPointer: two roots including the same file get
207
// the same *ast.Journal for it (one parse, shared).
208
func TestLoader_ParseCache_SharedPointer(t *testing.T) {
209
	l, dir := newLoaderDir(t, map[string]string{
210
		"a.journal": "include c.journal\n",
211
		"b.journal": "include c.journal\n",
212
		"c.journal": "account expenses:food\n",
213
	})
214
	c := filepath.Join(dir, "c.journal")
215
216
	ca := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), c)
217
	cb := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "b.journal")), c)
218
	if ca.Ast != cb.Ast {
219
		t.Error("c.journal parsed twice: Ast pointers differ")
220
	}
221
	if ca == cb {
222
		t.Error("ParsedFile wrapper must be per-resolve")
223
	}
224
}
225
226
// TestLoader_ParseCache_ContentChange: new content forces a new parse.
227
func TestLoader_ParseCache_ContentChange(t *testing.T) {
228
	l, dir := newLoaderDir(t, map[string]string{
229
		"a.journal": "include c.journal\n",
230
		"c.journal": "account expenses:food\n",
231
	})
232
	a, c := filepath.Join(dir, "a.journal"), filepath.Join(dir, "c.journal")
233
234
	first := mustOccurrence(t, mustResolve(t, l, a), c).Ast
235
	writeFiles(t, dir, map[string]string{"c.journal": "account expenses:travel\n"})
236
	l.InvalidateFile(c) // the content cache would serve stale bytes
237
	second := mustOccurrence(t, mustResolve(t, l, a), c).Ast
238
	if first == second {
239
		t.Error("edited file served the old parse")
240
	}
241
}
242
243
// TestLoader_ContentProvider: open-buffer text wins over disk for includes.
244
func TestLoader_ContentProvider(t *testing.T) {
245
	l, dir := newLoaderDir(t, map[string]string{
246
		"a.journal": "include c.journal\n",
247
		"c.journal": "account expenses:old\n",
248
	})
249
	l.ContentProvider = func(path string) ([]byte, bool) {
250
		if filepath.Base(path) == "c.journal" {
251
			return []byte("account expenses:new\n"), true
252
		}
253
		return nil, false
254
	}
255
256
	pf := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), filepath.Join(dir, "c.journal"))
257
	ad := pf.Ast.Entries[0].(*ast.AccountDirective)
258
	if ad.Account.String() != "expenses:new" {
259
		t.Errorf("included content = %q, want expenses:new (buffer wins over disk)", ad.Account.String())
260
	}
261
}