clerk/journal/loader_test.go (view raw)
Oleksandr Smirnov
Oleksandr Smirnov
olexsmir@gmail.com lsp: cache per-document analyses with include-aware invalidation..., 1 month ago
olexsmir@gmail.com lsp: cache per-document analyses with include-aware invalidation..., 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 != nil { |
| 78 | s += " " + e.Payee.Name |
| 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 | } |