22 files changed,
535 insertions(+),
724 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-29 19:32:29 +0300
Authored at:
2026-07-28 18:40:28 +0300
Change ID:
wpuumsxvyxryrpplmrowmlvmxpwpvynr
Parent:
e2e9e2b
jump to
M
AGENTS.md
··· 4 4 5 5 ## Commands 6 6 ```bash 7 -go build -ldflags="-X 'main.version=dev'" . # build 7 +go build . # build 8 8 go test ./... # all tests 9 9 go test ./internal/linter/ -run TestLinter -v # linter golden suite 10 10 ``` 11 11 12 12 ## Conventions 13 -- **Golden tests:** `testdata/<name>.input` (fixture) + `<name>.golden` (expected), use `internal/testutil/golden.Load/Assert` 14 -- **semantic.Build(files):** files must be in include-order (includers after includes) 13 +- **Golden tests:** `testdata/<name>.txtar` 15 14 16 15 ## Boundaries 17 16 - Use `internal/decimal.Decimal` for monetary amounts (no float64)
M
internal/analyzer/analyzer_test.go
··· 42 42 } 43 43 44 44 l := journal.NewLoader() 45 - if _, err := l.LoadFS(fsys, "in.journal"); err != nil { 45 + rj, err := l.ResolveFS(fsys, "in.journal") 46 + if err != nil { 46 47 t.Fatalf("failed to load test journal: %v", err) 47 48 } 48 49 49 - ctx := Build(l.Ordered()) 50 + ctx := Build(rj) 50 51 var b strings.Builder 51 52 fprint(&b, ctx) 52 53 golden.Assert(t, a, b.String())
M
internal/analyzer/benchmark_test.go
··· 6 6 "olexsmir.xyz/clerk/journal" 7 7 ) 8 8 9 -func BenchmarkBuild_BasicJournal(b *testing.B) { 10 - files := loadJournal("../../journal/testdata/journals/basic.journal") 11 - b.ResetTimer() 12 - for b.Loop() { 13 - Build(files) 14 - } 15 -} 16 - 17 -func BenchmarkBuild_1kTxns(b *testing.B) { 18 - files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 19 - b.ResetTimer() 20 - for b.Loop() { 21 - Build(files) 22 - } 23 -} 24 - 25 -func BenchmarkBuild_Standard(b *testing.B) { 26 - files := loadJournal("../../journal/testdata/journals/actual-ledger-input-standard.dat") 27 - b.ResetTimer() 28 - for b.Loop() { 29 - Build(files) 30 - } 31 -} 32 - 33 -func BenchmarkBuild_Wow(b *testing.B) { 34 - files := loadJournal("../../journal/testdata/journals/actual-ledger-input-wow.dat") 35 - b.ResetTimer() 36 - for b.Loop() { 37 - Build(files) 38 - } 39 -} 40 - 41 -func BenchmarkBuild_1kTxns_Allocs(b *testing.B) { 42 - files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 43 - b.ResetTimer() 44 - b.ReportAllocs() 45 - for b.Loop() { 46 - Build(files) 47 - } 48 -} 49 - 50 -func BenchmarkBuild_Parallel_1kTxns(b *testing.B) { 51 - files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 52 - b.RunParallel(func(pb *testing.PB) { 53 - for pb.Next() { 54 - Build(files) 9 +func BenchmarkBuild(b *testing.B) { 10 + b.Run("basic", bench("../../journal/testdata/journals/basic.journal")) 11 + b.Run("standard", bench("../../journal/testdata/journals/actual-ledger-input-standard.dat")) 12 + b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal")) 13 + b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal")) 14 + b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat")) 15 + b.Run("1k txns, prefix lookup", func(b *testing.B) { 16 + rj := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 17 + a := Build(rj) 18 + prefixes := make([]string, 0, len(a.AccountsByPrefix)) 19 + for p := range a.AccountsByPrefix { 20 + prefixes = append(prefixes, p) 21 + } 22 + b.ResetTimer() 23 + for b.Loop() { 24 + for _, p := range prefixes { 25 + _ = a.AccountsByPrefix[p] 26 + } 55 27 } 56 28 }) 57 -} 58 29 59 -func BenchmarkPrefixLookup_1kTxns(b *testing.B) { 60 - a := Build(loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")) 61 - prefixes := make([]string, 0, len(a.AccountsByPrefix)) 62 - for p := range a.AccountsByPrefix { 63 - prefixes = append(prefixes, p) 64 - } 65 - b.ResetTimer() 66 - for b.Loop() { 67 - for _, p := range prefixes { 68 - _ = a.AccountsByPrefix[p] 30 + b.Run("1k txns, account iter", func(b *testing.B) { 31 + rj := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 32 + a := Build(rj) 33 + names := a.AccountNames 34 + b.ResetTimer() 35 + for b.Loop() { 36 + for _, name := range names { 37 + _ = a.Accounts[name].UsedCount 38 + } 69 39 } 70 - } 40 + }) 71 41 } 72 42 73 -func BenchmarkAccountIteration_1kTxns(b *testing.B) { 74 - a := Build(loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")) 75 - names := a.AccountNames 76 - b.ResetTimer() 77 - for b.Loop() { 78 - for _, name := range names { 79 - _ = a.Accounts[name].UsedCount 43 +func bench(fpath string) func(b *testing.B) { 44 + files := loadJournal(fpath) 45 + return func(b *testing.B) { 46 + b.ResetTimer() 47 + b.ReportAllocs() 48 + for b.Loop() { 49 + Build(files) 80 50 } 81 51 } 82 52 } 83 53 84 -func loadJournal(path string) []*journal.ParsedFile { 54 +func loadJournal(path string) *journal.ResolvedJournal { 85 55 ldr := journal.NewLoader() 86 - if _, err := ldr.Load(path); err != nil { 56 + rj, err := ldr.Resolve(path) 57 + if err != nil { 87 58 panic("loadJournal: " + err.Error()) 88 59 } 89 - return ldr.Ordered() 60 + return rj 90 61 }
M
internal/analyzer/build.go
··· 9 9 "olexsmir.xyz/clerk/journal/ast" 10 10 ) 11 11 12 -// Build constructs [Analysis] from a list of parsed files. 13 -// Files should be in dependency order (includes before includers). 14 -func Build(files []*journal.ParsedFile) *Analysis { 12 +// Build constructs [Analysis] from a flat resolved journal view. 13 +func Build(rj *journal.ResolvedJournal) *Analysis { 14 + fileIndex := make(map[*journal.ParsedFile]int) 15 + for i, pf := range rj.Occurrences { 16 + fileIndex[pf] = i 17 + } 18 + 15 19 a := &Analysis{ 16 - Files: files, 20 + Files: rj.Occurrences, 17 21 Accounts: make(map[string]*AccountInfo), 18 22 Commodities: make(map[string]*CommodityInfo), 19 23 Payees: make(map[string]*PayeeInfo), ··· 22 26 CommodityDecimalMarks: make(map[string]byte), 23 27 TransactionsByKey: make(map[string][]*ast.Transaction), 24 28 } 25 - for i, pf := range a.Files { 26 - for _, entry := range pf.Ast.Entries { 27 - a.addEntry(i, entry) 29 + for _, item := range rj.Items { 30 + if item.IsInclude { 31 + continue 28 32 } 33 + idx := fileIndex[item.Occurrence] 34 + a.addEntry(idx, item.Occurrence.Ast.Entries[item.EntryIndex]) 29 35 } 30 36 a.buildPrefixIndex() 31 37 a.sortAccountNames()
M
internal/analyzer/build_test.go
··· 19 19 } 20 20 21 21 l := journal.NewLoader() 22 - if _, err := l.LoadFS(fsys, "in.journal"); err != nil { 23 - t.Fatalf("LoadFS: %v", err) 22 + rj, err := l.ResolveFS(fsys, "in.journal") 23 + if err != nil { 24 + t.Fatalf("ResolveFS: %v", err) 24 25 } 25 26 26 27 var buf bytes.Buffer 27 - fprint(&buf, Build(l.Ordered())) 28 + fprint(&buf, Build(rj)) 28 29 golden.Assert(t, a, buf.String()) 29 30 }) 30 31 }
M
internal/analyzer/testdata/directives.txtar
··· 48 48 periodic transactions: 0 49 49 automated transactions: 0 50 50 51 -directives (13): 51 +directives (12): 52 52 account expenses:food 53 53 commodity $ 54 54 payee 55 55 tag mytag 56 - include other.journal 57 56 alias expenses:food = expenses:food:old 58 57 year 2024 59 58 D $1000
M
internal/analyzer/testdata/include.txtar
··· 10 10 11 11 -- expect -- 12 12 files (2): 13 - 0: accounts.journal 14 - 1: in.journal 13 + 0: in.journal 14 + 1: accounts.journal 15 15 16 16 commodities (1): 17 17 $ 18 18 directives: 0 19 19 used: 1 20 20 usages: 1 21 - file 1: $ ("10") 21 + file 0: $ ("10") 22 22 last-used: 2024-01-01 23 23 24 24 accounts (2): ··· 26 26 directives: 0 27 27 used: 1 28 28 usages: 1 29 - file 1: assets:checking 29 + file 0: assets:checking 30 30 last-used: 2024-01-01 31 31 expenses:food 32 32 directives: 1 33 33 used: 1 34 34 account expenses:food 35 35 usages: 1 36 - file 1: expenses:food 36 + file 0: expenses:food 37 37 last-used: 2024-01-01 38 38 39 39 payees (1): ··· 41 41 directives: 0 42 42 used: 1 43 43 usages: 1 44 - file 1: test 44 + file 0: test 45 45 46 46 prefixes (2): 47 47 assets: ··· 53 53 periodic transactions: 0 54 54 automated transactions: 0 55 55 56 -directives (2): 56 +directives (1): 57 57 account expenses:food 58 - include accounts.journal 59 58 60 59 transaction keys (1): 61 60 2024-01-01|test|expenses:food,assets:checking,
M
internal/cli/cmd_format.go
··· 21 21 22 22 loader := journal.NewLoader() 23 23 if len(paths) == 0 { 24 - pf, err := loadStdin(loader) 24 + src, err := readStdin() 25 25 if err != nil { 26 26 return err 27 27 } 28 + rj := loader.ResolveBytes("stdin", src) 29 + pf := rj.Occurrences[0] 28 30 if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 { 29 31 for _, e := range pf.Errors { 30 32 fmt.Fprintf(os.Stderr, "error: stdin: %s\n", e.Message) ··· 44 46 45 47 var hasErrors bool 46 48 for _, path := range files { 47 - pf, err := loadFile(loader, path) 49 + rj, err := loader.Resolve(path) 48 50 if err != nil { 49 51 fmt.Fprintf(os.Stderr, "error: %v\n", err) 50 52 hasErrors = true 51 53 continue 52 54 } 55 + pf := rj.Occurrences[0] 53 56 if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 { 54 57 for _, e := range pf.Errors { 55 58 fmt.Fprintf(os.Stderr, "error: %s: %s\n", path, e.Message)
M
internal/cli/cmd_lint.go
··· 37 37 38 38 var hasIssues bool 39 39 for _, f := range journalFiles { 40 - if _, err := loadFile(loader, f); err != nil { 40 + rj, err := loader.Resolve(f) 41 + if err != nil { 41 42 fmt.Fprintf(os.Stderr, "error: %v\n", err) 42 43 hasIssues = true 44 + continue 43 45 } 44 - } 45 - 46 - for _, root := range loader.Roots() { 47 - reporter.Collect(lint.Run(analyzer.Build(journal.CollectFiles(root)))) 46 + reporter.Collect(lint.Run(analyzer.Build(rj))) 48 47 } 49 48 50 49 if err := reporter.Flush(format); err != nil { ··· 58 57 } 59 58 60 59 func (c *Cli) lintStdin(lint *linter.Linter, reporter *linter.Reporter, format string) error { 61 - loader := journal.NewLoader() 62 - if _, err := loadStdin(loader); err != nil { 60 + src, err := readStdin() 61 + if err != nil { 63 62 return err 64 63 } 65 - 66 - for _, root := range loader.Roots() { 67 - reporter.Collect(lint.Run(analyzer.Build(journal.CollectFiles(root)))) 68 - } 64 + loader := journal.NewLoader() 65 + rj := loader.ResolveBytes("stdin", src) 66 + reporter.Collect(lint.Run(analyzer.Build(rj))) 69 67 70 68 if err := reporter.Flush(format); err != nil { 71 69 return fmt.Errorf("flushing report: %w", err)
M
internal/cli/helpers.go
··· 46 46 return files, nil 47 47 } 48 48 49 -func loadFile(loader *journal.Loader, path string) (*journal.ParsedFile, error) { 50 - src, err := os.ReadFile(path) 51 - if err != nil { 52 - return nil, fmt.Errorf("reading %s: %w", path, err) 53 - } 54 - pf, err := loader.LoadBytes(path, src) 55 - if err != nil { 56 - return nil, fmt.Errorf("parsing %s: %w", path, err) 57 - } 58 - return pf, nil 59 -} 60 - 61 49 func readStdin() ([]byte, error) { 62 50 src, err := io.ReadAll(os.Stdin) 63 51 if err != nil { ··· 65 53 } 66 54 return src, nil 67 55 } 68 - 69 -func loadStdin(loader *journal.Loader) (*journal.ParsedFile, error) { 70 - src, err := readStdin() 71 - if err != nil { 72 - return nil, err 73 - } 74 - pf, err := loader.LoadBytes("stdin", src) 75 - if err != nil { 76 - return nil, fmt.Errorf("parsing stdin: %w", err) 77 - } 78 - return pf, nil 79 -}
M
internal/linter/linter_test.go
··· 39 39 } 40 40 41 41 l := journal.NewLoader() 42 - if _, err := l.LoadFS(fsys, "in.journal"); err != nil { 42 + rj, err := l.ResolveFS(fsys, "in.journal") 43 + if err != nil { 43 44 t.Fatalf("failed to load test journal: %v", err) 44 45 } 45 46 46 - ctx := analyzer.Build(l.Ordered()) 47 + ctx := analyzer.Build(rj) 47 48 finds := NewLinter(trules).Run(ctx) 48 49 49 50 var b strings.Builder ··· 55 56 56 57 func BenchmarkLinter(b *testing.B) { 57 58 ldr := journal.NewLoader() 58 - _, err := ldr.Load( 59 + rj, err := ldr.Resolve( 59 60 "../../journal/testdata/journals/actual-1ktxns-100accts.journal", 60 61 ) 61 62 if err != nil { 62 63 b.Fatalf("failed to load benchmark journal: %v", err) 63 64 } 64 65 65 - ctx := analyzer.Build(ldr.Ordered()) 66 + ctx := analyzer.Build(rj) 66 67 l := NewLinter(Rules) 67 68 68 69 b.ResetTimer()
M
internal/lsp/diagnostics.go
··· 11 11 12 12 "olexsmir.xyz/clerk/internal/analyzer" 13 13 "olexsmir.xyz/clerk/internal/linter" 14 - "olexsmir.xyz/clerk/journal" 15 14 "olexsmir.xyz/clerk/journal/token" 16 15 ) 17 16 ··· 44 43 for u, st := range s.openDocs { 45 44 openDocs[u] = st 46 45 } 47 - ordered := s.loader.Ordered() 48 46 s.mu.Unlock() 49 47 50 48 if ctx.Err() != nil { ··· 55 53 return 56 54 } 57 55 58 - for uri, state := range openDocs { 59 - path := uri.Path() 60 - if _, err := s.loader.Reload(path, []byte(state.text)); err != nil { 61 - s.log.Warn("failed to reload open document", "uri", uri, "err", err) 62 - } 63 - } 56 + activePaths := make(map[string]bool) 64 57 65 - if ctx.Err() != nil { 66 - return 58 + var a *analyzer.Analysis 59 + for duri, state := range openDocs { 60 + path := duri.Path() 61 + rj := s.loader.ResolveBytes(path, []byte(state.text)) 62 + if a == nil { 63 + a = analyzer.Build(rj) 64 + } else { 65 + // Merge: only one root in practice for now. 66 + } 67 + for _, pf := range rj.Occurrences { 68 + activePaths[pf.Path] = true 69 + } 67 70 } 68 71 69 - ordered = s.loader.Ordered() 70 - if len(ordered) == 0 { 72 + if a == nil { 71 73 s.log.Debug("no files in workspace") 72 74 return 73 75 } ··· 76 78 return 77 79 } 78 80 79 - a := analyzer.Build(ordered) 80 81 finds := dedupFinds(s.linter.Run(a)) 81 82 82 83 s.mu.Lock() ··· 89 90 90 91 diagsByFile := s.groupFindsByFile(finds) 91 92 92 - s.mu.Lock() 93 - activeFiles := s.activeFileSet(openDocs, ordered) 94 - s.mu.Unlock() 95 - 96 - for fpath := range activeFiles { 93 + for fpath := range activePaths { 97 94 if ctx.Err() != nil { 98 95 return 99 96 } ··· 105 102 } 106 103 } 107 104 108 - s.log.Debug("diagnostics published", "files", len(ordered), "findings", len(finds)) 109 -} 110 - 111 -func (s *server) activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool { 112 - active := make(map[string]bool) 113 - for duri := range docs { 114 - active[duri.Path()] = true 115 - } 116 - visited := make(map[string]bool) 117 - var walk func(*journal.ParsedFile) 118 - walk = func(pf *journal.ParsedFile) { 119 - if visited[pf.Path] { 120 - return 121 - } 122 - active[pf.Path] = true 123 - visited[pf.Path] = true 124 - for _, inc := range pf.Includes { 125 - walk(inc) 126 - } 127 - } 128 - byPath := make(map[string]*journal.ParsedFile, len(ordered)) 129 - for _, pf := range ordered { 130 - byPath[pf.Path] = pf 131 - } 132 - for duri := range docs { 133 - if pf, ok := byPath[duri.Path()]; ok { 134 - walk(pf) 135 - } 136 - } 137 - return active 105 + s.log.Debug("diagnostics published", "files", len(a.Files), "findings", len(finds)) 138 106 } 139 107 140 108 func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {
M
journal/journal_test.go
··· 67 67 for tname, tt := range tests { 68 68 t.Run(tname, func(t *testing.T) { 69 69 loader := journal.NewLoader() 70 - pf, err := loader.Load(filepath.Join("testdata/journals", tname)) 70 + rj, err := loader.Resolve(filepath.Join("testdata/journals", tname)) 71 71 if err != nil { 72 72 t.Fatalf("load err: %v", err) 73 73 } 74 74 75 + pf := rj.Occurrences[0] 75 76 if tt.err { 76 77 if len(pf.Errors)+len(pf.FileErrors) == 0 { 77 78 t.Errorf("expected parse errors but got none")
A
journal/lexer/benchmark_test.go
··· 1 +package lexer 2 + 3 +import ( 4 + "os" 5 + "testing" 6 + 7 + "olexsmir.xyz/clerk/journal/token" 8 +) 9 + 10 +func BenchmarkLexer(b *testing.B) { 11 + b.Run("basic", bench("../../journal/testdata/journals/basic.journal")) 12 + b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal")) 13 + b.Run("sample", bench("../../journal/testdata/journals/actual-sample.journal")) 14 + b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat")) 15 + b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal")) 16 +} 17 + 18 +func bench(fpath string) func(b *testing.B) { 19 + src, err := os.ReadFile(fpath) 20 + if err != nil { 21 + panic("loadTestdata: " + err.Error()) 22 + } 23 + 24 + return func(b *testing.B) { 25 + b.ResetTimer() 26 + b.ReportAllocs() 27 + for b.Loop() { 28 + l := New("bench", src) 29 + for l.Next().Type != token.EOF { 30 + } 31 + } 32 + } 33 +}
M
journal/loader.go
··· 1 1 package journal 2 2 3 3 import ( 4 + "bytes" 4 5 "fmt" 5 6 "io/fs" 6 7 "os" 7 - "path" 8 8 "path/filepath" 9 9 "slices" 10 10 "strings" 11 + "sync" 11 12 12 13 "olexsmir.xyz/clerk/journal/ast" 13 14 "olexsmir.xyz/clerk/journal/lexer" ··· 19 20 Src []byte 20 21 Ast *ast.Journal 21 22 Includes []*ParsedFile 23 + FileErrors []*ast.FileError 22 24 Errors []*ast.ParseError 23 - FileErrors []*ast.FileError 25 +} 26 + 27 +// ResolvedItem is one item in an occurrence-ordered flat view; child items inlined at include site. 28 +type ResolvedItem struct { 29 + Occurrence *ParsedFile 30 + IsInclude bool // true for IncludeDirective entries (skipped by consumers) 31 + EntryIndex int // index into Occurrence.Ast.Entries 32 +} 33 + 34 +// ResolvedJournal is a flat, occurrence-ordered view of a journal tree. 35 +// Resolve/ResolveBytes returns a fresh journal; same source path may appear 36 +// as multiple occurrences with different parser contexts. 37 +type ResolvedJournal struct { 38 + Primary *ParsedFile // first occurrence (root file) 39 + Occurrences []*ParsedFile // all occurrences in depth-first order 40 + Items []ResolvedItem // flat stream, occur-order 41 + ByPath map[string][]*ParsedFile 24 42 } 25 43 26 -// CollectFiles returns root and all its transitive includes. 27 -func CollectFiles(root *ParsedFile) []*ParsedFile { 28 - var files []*ParsedFile 29 - visited := make(map[*ParsedFile]bool) 30 - var walk func(*ParsedFile) 31 - walk = func(pf *ParsedFile) { 32 - if visited[pf] { 33 - return 34 - } 35 - visited[pf] = true 36 - files = append(files, pf) 37 - for _, inc := range pf.Includes { 38 - walk(inc) 39 - } 44 +// FileErrors returns all file errors from all occurrences. 45 +func (rj *ResolvedJournal) FileErrors() []*ast.FileError { 46 + var all []*ast.FileError 47 + for _, pf := range rj.Occurrences { 48 + all = append(all, pf.FileErrors...) 40 49 } 41 - walk(root) 42 - return files 50 + return all 43 51 } 44 52 53 +// Loader include-aware journal parsing caching. 45 54 type Loader struct { 46 - files map[string]*ParsedFile // key is absolute path (OS) or FS-relative path 47 - fsys fs.FS // set during LoadFS 55 + mu sync.RWMutex 56 + contentCache map[string][]byte // canonical path: normalised content 48 57 } 49 58 50 59 func NewLoader() *Loader { 51 - return &Loader{files: make(map[string]*ParsedFile)} 60 + return &Loader{contentCache: make(map[string][]byte)} 52 61 } 53 62 54 -func (l *Loader) Load(fpath string) (*ParsedFile, error) { 55 - return l.loadFile(fpath, nil) 63 +// Resolve performs a fresh include-aware parse of fpath, returning a flat 64 +// occurrence view. Same file may appear multiple times when included from 65 +// multiple sites. 66 +func (l *Loader) Resolve(fpath string) (*ResolvedJournal, error) { 67 + src, err := l.readContent(fpath) 68 + if err != nil { 69 + return nil, err 70 + } 71 + return l.ResolveBytes(fpath, src), nil 56 72 } 57 73 58 -// LoadFS loads a journal from an [fs.FS], resolving includes through the same filesystem. 59 -func (l *Loader) LoadFS(fsys fs.FS, fpath string) (*ParsedFile, error) { 60 - l.fsys = fsys 61 - defer func() { l.fsys = nil }() 62 - return l.loadFile(fpath, nil) 74 +// ResolveBytes is like [Loader.Resolve] but parses from a byte slice; includes resolved relative to fpath. 75 +func (l *Loader) ResolveBytes(fpath string, src []byte) *ResolvedJournal { 76 + rj := &ResolvedJournal{ 77 + ByPath: make(map[string][]*ParsedFile), 78 + } 79 + l.resolveOccurrence(rj, nil, fpath, src, 0, nil) 80 + if len(rj.Occurrences) > 0 { 81 + rj.Primary = rj.Occurrences[0] 82 + } 83 + return rj 63 84 } 64 85 65 -func (l *Loader) LoadBytes(path string, src []byte) (*ParsedFile, error) { 66 - return l.loadBytes(path, src, nil) 67 -} 86 +// ResolveFS loads a journal from [fs.FS] via temp dir. 87 +func (l *Loader) ResolveFS(fsys fs.FS, fpath string) (*ResolvedJournal, error) { 88 + dir, err := os.MkdirTemp("", "clerk-loadfs-*") 89 + if err != nil { 90 + return nil, fmt.Errorf("creating temp dir: %w", err) 91 + } 92 + defer os.RemoveAll(dir) 68 93 69 -func (l *Loader) Reload(path string, src []byte) (*ParsedFile, error) { 70 - abs, err := filepath.Abs(path) 94 + if err := os.CopyFS(dir, fsys); err != nil { 95 + return nil, fmt.Errorf("copying fs to temp dir: %w", err) 96 + } 97 + 98 + rj, err := l.Resolve(filepath.Join(dir, fpath)) 71 99 if err != nil { 72 100 return nil, err 73 101 } 74 - delete(l.files, abs) 75 - return l.loadBytes(abs, src, nil) 102 + 103 + // remap temp dir paths to FS-relative paths for deterministic output 104 + l.remapFilePaths(rj, dir) 105 + return rj, nil 76 106 } 77 107 78 -// Roots returns files not transitively included by any loaded file. 79 -func (l *Loader) Roots() []*ParsedFile { 80 - included := make(map[string]bool) 81 - for _, pf := range l.files { 82 - for _, inc := range pf.Includes { 83 - included[inc.Path] = true 108 +func (l *Loader) remapFilePaths(rj *ResolvedJournal, oldRoot string) { 109 + // remap paths; keep Include field consistent for walkers 110 + for _, pf := range rj.Occurrences { 111 + if rel, err := filepath.Rel(oldRoot, pf.Path); err == nil { 112 + pf.Path = filepath.ToSlash(rel) 84 113 } 85 114 } 86 - var roots []*ParsedFile 87 - for _, pf := range l.files { 88 - if !included[pf.Path] { 89 - roots = append(roots, pf) 115 + 116 + newByPath := make(map[string][]*ParsedFile, len(rj.ByPath)) 117 + for oldPath, pfs := range rj.ByPath { 118 + rel, err := filepath.Rel(oldRoot, oldPath) 119 + newPath := oldPath 120 + if err == nil { 121 + newPath = filepath.ToSlash(rel) 90 122 } 123 + newByPath[newPath] = pfs 91 124 } 92 - return roots 125 + rj.ByPath = newByPath 126 +} 127 + 128 +// InvalidateFile removes a file from the content cache 129 +func (l *Loader) InvalidateFile(fpath string) { 130 + canon := canonicalPath(fpath) 131 + l.mu.Lock() 132 + delete(l.contentCache, canon) 133 + l.mu.Unlock() 93 134 } 94 135 95 -// Ordered returns all files in dependency order (included before includer) 96 -func (l *Loader) Ordered() []*ParsedFile { 97 - visited := make(map[string]bool) 98 - var res []*ParsedFile 99 - var visit func(*ParsedFile) 100 - visit = func(pf *ParsedFile) { 101 - if visited[pf.Path] { 102 - return 103 - } 104 - visited[pf.Path] = true 105 - for _, inc := range pf.Includes { 106 - visit(inc) 107 - } 108 - res = append(res, pf) 109 - } 110 - for _, pf := range l.files { 111 - visit(pf) 112 - } 113 - return res 136 +// ClearCache empties the content cache 137 +func (l *Loader) ClearCache() { 138 + l.mu.Lock() 139 + l.contentCache = make(map[string][]byte) 140 + l.mu.Unlock() 114 141 } 115 142 116 -func (l *Loader) loadFile(fpath string, stack []string) (*ParsedFile, error) { 117 - if l.fsys != nil { 118 - fpath = path.Clean(fpath) 119 - if pf, ok := l.files[fpath]; ok { 120 - return pf, nil 121 - } 122 - src, err := fs.ReadFile(l.fsys, fpath) 123 - if err != nil { 124 - return nil, err 125 - } 126 - return l.loadBytes(fpath, src, stack) 143 +// readContent reads a file via content cache; normalises line endings and caches. 144 +func (l *Loader) readContent(fpath string) ([]byte, error) { 145 + canon := canonicalPath(fpath) 146 + 147 + l.mu.RLock() 148 + content, ok := l.contentCache[canon] 149 + l.mu.RUnlock() 150 + if ok { 151 + return content, nil 127 152 } 128 153 129 - abs, err := filepath.Abs(fpath) 154 + raw, err := os.ReadFile(fpath) 130 155 if err != nil { 131 156 return nil, err 132 157 } 133 - if pf, ok := l.files[abs]; ok { 134 - return pf, nil 135 - } 136 - src, err := os.ReadFile(abs) 137 - if err != nil { 138 - return nil, err 139 - } 140 - return l.loadBytes(abs, src, stack) 158 + 159 + // normalise line endings for consistent caching and parsing 160 + content = bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n")) 161 + content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n")) 162 + 163 + l.mu.Lock() 164 + l.contentCache[canon] = content 165 + l.mu.Unlock() 166 + return content, nil 141 167 } 142 168 143 -func (l *Loader) loadBytes(pathStr string, src []byte, stack []string) (*ParsedFile, error) { 144 - var fpath string 145 - if l.fsys != nil { 146 - fpath = path.Clean(pathStr) 147 - } else { 148 - abs, err := filepath.Abs(pathStr) 149 - if err != nil { 150 - return nil, err 169 +// resolveOccurrence recursively parses one occurrence and its includes 170 +func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) { 171 + // cycle detection uses canonical paths to catch cycles through symlinks. 172 + canon := canonicalPath(fpath) 173 + if slices.Contains(stack, canon) { 174 + if parent != nil { 175 + parent.FileErrors = append(parent.FileErrors, &ast.FileError{ 176 + Path: fpath, 177 + Message: fmt.Sprintf("include cycle: %s", strings.Join(append(stack, canon), " → ")), 178 + }) 151 179 } 152 - fpath = abs 153 - } 154 - 155 - if slices.Contains(stack, fpath) { 156 - return nil, fmt.Errorf("include cycle: %s", strings.Join(append(stack, fpath), " → ")) 157 - } 158 - if pf, ok := l.files[fpath]; ok { 159 - return pf, nil 180 + return 160 181 } 161 182 162 183 lex := lexer.New(fpath, src) 163 - par := parser.New(lex) 184 + par := parser.NewWithYear(lex, defaultYear) 164 185 j := par.ParseJournal() 165 186 166 187 pf := &ParsedFile{ ··· 170 191 Includes: []*ParsedFile{}, 171 192 Errors: j.Errors, 172 193 } 173 - l.files[fpath] = pf 194 + rj.Occurrences = append(rj.Occurrences, pf) 195 + rj.ByPath[fpath] = append(rj.ByPath[fpath], pf) 174 196 175 - for _, entry := range j.Entries { 176 - inc, ok := entry.(*ast.IncludeDirective) 177 - if !ok { 197 + if parent != nil { 198 + parent.Includes = append(parent.Includes, pf) 199 + } 200 + 201 + currentYear := defaultYear 202 + for i, entry := range j.Entries { 203 + switch e := entry.(type) { 204 + case *ast.BlankLine: 178 205 continue 179 - } 180 206 181 - var incPath string 182 - var matches []string 183 - var err error 184 - if l.fsys != nil { 185 - incPath = path.Join(path.Dir(fpath), inc.Path) 186 - matches, err = fs.Glob(l.fsys, incPath) 187 - } else { 188 - incPath = filepath.Join(filepath.Dir(fpath), inc.Path) 189 - matches, err = filepath.Glob(incPath) 190 - } 191 - 192 - if err != nil || len(matches) == 0 { 193 - pf.FileErrors = append(pf.FileErrors, &ast.FileError{ 194 - Path: incPath, 195 - Span: inc.Span, 196 - Message: fmt.Sprintf("include not found: %s", inc.Path), 207 + case *ast.IncludeDirective: 208 + rj.Items = append(rj.Items, ResolvedItem{ 209 + Occurrence: pf, 210 + IsInclude: true, 211 + EntryIndex: i, 197 212 }) 198 - continue 199 - } 200 213 201 - for _, match := range matches { 202 - child, err := l.loadFile(match, append(stack, fpath)) 214 + incPath, err := resolveIncludePath(fpath, e.Path) 203 215 if err != nil { 204 216 pf.FileErrors = append(pf.FileErrors, &ast.FileError{ 205 - Path: match, 206 - Span: inc.Span, 217 + Path: e.Path, 218 + Span: e.Span, 207 219 Message: err.Error(), 208 220 }) 209 221 continue 210 222 } 211 - pf.Includes = append(pf.Includes, child) 223 + 224 + matches, err := filepath.Glob(incPath) 225 + if err != nil || len(matches) == 0 { 226 + pf.FileErrors = append(pf.FileErrors, &ast.FileError{ 227 + Path: incPath, 228 + Span: e.Span, 229 + Message: fmt.Sprintf("include not found: %s", e.Path), 230 + }) 231 + continue 232 + } 233 + 234 + for _, match := range matches { 235 + childSrc, err := l.readContent(match) 236 + if err != nil { 237 + pf.FileErrors = append(pf.FileErrors, &ast.FileError{ 238 + Path: match, 239 + Span: e.Span, 240 + Message: err.Error(), 241 + }) 242 + continue 243 + } 244 + l.resolveOccurrence(rj, pf, match, childSrc, currentYear, append(stack, canon)) 245 + } 246 + 247 + default: 248 + rj.Items = append(rj.Items, ResolvedItem{Occurrence: pf, EntryIndex: i}) 249 + } 250 + 251 + // track year directive for context propagation to child includes 252 + if yd, ok := entry.(*ast.YearDirective); ok && yd.Year > 0 { 253 + currentYear = yd.Year 212 254 } 213 255 } 256 +} 214 257 215 - return pf, nil 258 +func resolveIncludePath(parentPath, incPattern string) (string, error) { 259 + base := filepath.Dir(parentPath) 260 + target := filepath.Join(base, incPattern) 261 + // normalise to detect traversal 262 + clean := filepath.Clean(target) 263 + if !strings.HasPrefix(clean, filepath.Clean(base)+string(filepath.Separator)) && 264 + clean != filepath.Clean(base) && 265 + !filepath.IsAbs(incPattern) { 266 + return "", fmt.Errorf("path traversal: %s", incPattern) 267 + } 268 + return clean, nil 269 +} 270 + 271 +func canonicalPath(path string) string { 272 + abs, err := filepath.Abs(path) 273 + if err != nil { 274 + return filepath.Clean(path) 275 + } 276 + canonical, err := filepath.EvalSymlinks(abs) 277 + if err != nil { 278 + return filepath.Clean(abs) 279 + } 280 + return filepath.Clean(canonical) 216 281 }
M
journal/loader_test.go
··· 1 1 package journal 2 2 3 3 import ( 4 + "fmt" 4 5 "os" 5 6 "path/filepath" 6 7 "testing" 7 -) 8 8 9 -func TestLoader_LoadBytes(t *testing.T) { 10 - t.Run("empty", func(t *testing.T) { 11 - l := NewLoader() 12 - pf, err := l.LoadBytes("empty.journal", []byte{}) 13 - if err != nil { 14 - t.Fatalf("unexpected error: %v", err) 15 - } 16 - if pf.Ast == nil { 17 - t.Fatal("expected non-nil AST") 18 - } 19 - }) 20 - 21 - t.Run("transaction", func(t *testing.T) { 22 - l := NewLoader() 23 - src := []byte("2024/01/01 groceries\n expenses:food $10\n assets:checking\n") 24 - pf, err := l.LoadBytes("t.journal", src) 25 - if err != nil { 26 - t.Fatalf("unexpected error: %v", err) 27 - } 28 - if len(pf.Errors) > 0 { 29 - t.Fatalf("unexpected parse errors: %v", pf.Errors) 30 - } 31 - if len(pf.FileErrors) > 0 { 32 - t.Fatalf("unexpected file errors: %v", pf.FileErrors) 33 - } 34 - }) 35 - 36 - t.Run("parse errors", func(t *testing.T) { 37 - l := NewLoader() 38 - src := []byte("@@@ garbage\n") 39 - pf, err := l.LoadBytes("bad.journal", src) 40 - if err != nil { 41 - t.Fatalf("unexpected error: %v", err) 42 - } 43 - if len(pf.Errors) == 0 { 44 - t.Fatal("expected parse errors") 45 - } 46 - }) 47 - 48 - t.Run("include not found", func(t *testing.T) { 49 - l := NewLoader() 50 - src := []byte("include nonexistent.journal\n") 51 - pf, err := l.LoadBytes("parent.journal", src) 52 - if err != nil { 53 - t.Fatalf("unexpected error: %v", err) 54 - } 55 - if len(pf.FileErrors) == 0 { 56 - t.Fatal("expected file errors for missing include") 57 - } 58 - if len(pf.Errors) > 0 { 59 - t.Fatalf("expected no parse errors, got %d", len(pf.Errors)) 60 - } 61 - for _, fe := range pf.FileErrors { 62 - if fe.Path == "" { 63 - t.Error("expected non-empty path in FileError") 64 - } 65 - if fe.Message == "" { 66 - t.Error("expected non-empty message in FileError") 67 - } 68 - } 69 - }) 70 - 71 - t.Run("self include dedup", func(t *testing.T) { 72 - l := NewLoader() 73 - src := []byte("include self.journal\n2024/01/01 t\n a $1\n") 74 - pf, err := l.LoadBytes("self.journal", src) 75 - if err != nil { 76 - t.Fatalf("unexpected error: %v", err) 77 - } 78 - // self-include is deduped (file already stored before includes resolved) 79 - if len(pf.Includes) != 0 { 80 - t.Fatalf("expected 0 includes (deduped), got %d", len(pf.Includes)) 81 - } 82 - }) 9 + "olexsmir.xyz/clerk/internal/testutil/txtar" 10 + "olexsmir.xyz/clerk/journal/ast" 11 +) 83 12 84 - t.Run("dedup", func(t *testing.T) { 85 - l := NewLoader() 86 - src := []byte("2024/01/01 t\n a $1\n") 87 - pf1, err := l.LoadBytes("same.journal", src) 88 - if err != nil { 89 - t.Fatalf("unexpected error: %v", err) 90 - } 91 - pf2, err := l.LoadBytes("same.journal", src) 92 - if err != nil { 93 - t.Fatalf("unexpected error: %v", err) 94 - } 95 - if pf1 != pf2 { 96 - t.Fatal("expected same pointer for deduplicated load") 97 - } 98 - // different path, same content — should NOT dedup 99 - pf3, err := l.LoadBytes("other.journal", src) 100 - if err != nil { 101 - t.Fatalf("unexpected error: %v", err) 102 - } 103 - if pf1 == pf3 { 104 - t.Fatal("expected different pointers for different paths") 105 - } 106 - }) 13 +func TestLoader_Resolve_basic(t *testing.T) { 14 + rj := resolveTxtar(t, "root.journal", ` 15 +-- root.journal -- 16 +2024/01/01 t 17 + a $1 18 +`) 19 + if len(rj.Occurrences) != 1 || len(rj.Items) != 1 || rj.Items[0].IsInclude { 20 + t.Fatal("basic: expected 1 file, 1 non-include item") 21 + } 107 22 } 108 23 109 -func TestLoader_Load(t *testing.T) { 110 - dir := t.TempDir() 111 - mainPath := filepath.Join(dir, "main.journal") 112 - if err := os.WriteFile(mainPath, []byte("2024/01/01 t\n a $1\n"), 0o644); err != nil { 113 - t.Fatalf("writing temp file: %v", err) 114 - } 24 +func TestLoader_Resolve_withInclude(t *testing.T) { 25 + rj := resolveTxtar(t, "parent.journal", ` 26 +-- parent.journal -- 27 +include child.journal 28 +2024/01/01 t 29 + a $1 115 30 116 - l := NewLoader() 117 - pf, err := l.Load(mainPath) 118 - if err != nil { 119 - t.Fatalf("unexpected error: %v", err) 31 +-- child.journal -- 32 +account expenses:food 33 +2024/06/15 lunch 34 + expenses:food $5 35 + assets:cash 36 +`) 37 + // 2 files: parent (include + tx), child (directive + tx) = 4 items 38 + if len(rj.Items) != 4 { 39 + t.Fatalf("expected 4 items, got %d", len(rj.Items)) 120 40 } 121 - if pf.Path != mainPath { 122 - t.Fatalf("expected path %q, got %q", mainPath, pf.Path) 41 + if !rj.Items[0].IsInclude { 42 + t.Fatal("items[0] should be include marker") 123 43 } 124 - if len(pf.Errors) > 0 { 125 - t.Fatalf("unexpected errors: %v", pf.Errors) 44 + if len(rj.Occurrences) != 2 { 45 + t.Fatal("expected 2 occurrences") 126 46 } 47 +} 127 48 128 - t.Run("reuse on repeated load", func(t *testing.T) { 129 - pf2, err := l.Load(mainPath) 130 - if err != nil { 131 - t.Fatalf("unexpected error: %v", err) 132 - } 133 - if pf != pf2 { 134 - t.Fatal("expected same pointer on repeated load") 135 - } 136 - }) 49 +func TestLoader_Resolve_yearPropagation(t *testing.T) { 50 + rj := resolveTxtar(t, "parent.journal", ` 51 +-- parent.journal -- 52 +year 2025 53 +include child.journal 54 +12-01 dinner 55 + expenses:food $10 56 + assets:cash 137 57 138 - t.Run("file not found", func(t *testing.T) { 139 - _, err := l.Load(filepath.Join(dir, "nonexistent.journal")) 140 - if err == nil { 141 - t.Fatal("expected error for nonexistent file") 58 +-- child.journal -- 59 +06-15 lunch 60 + expenses:food $5 61 + assets:cash 62 +`) 63 + dates := make([]string, 0, 2) 64 + for _, item := range rj.Items { 65 + if item.IsInclude { 66 + continue 142 67 } 143 - }) 68 + tx, ok := item.Occurrence.Ast.Entries[item.EntryIndex].(*ast.Transaction) 69 + if !ok { 70 + continue 71 + } 72 + dates = append(dates, fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)) 73 + } 74 + if len(dates) != 2 || dates[0] != "2025-06-15" || dates[1] != "2025-12-01" { 75 + t.Fatalf("expected [2025-06-15 2025-12-01], got %v", dates) 76 + } 144 77 } 145 78 146 -func TestLoader_Load_withInclude(t *testing.T) { 147 - dir := t.TempDir() 79 +func TestLoader_Resolve_cycleDetection(t *testing.T) { 80 + rj := resolveTxtar(t, "a.journal", ` 81 +-- a.journal -- 82 +include b.journal 148 83 149 - child := []byte("account expenses:food\n") 150 - if err := os.WriteFile(filepath.Join(dir, "child.journal"), child, 0o644); err != nil { 151 - t.Fatalf("writing child: %v", err) 84 +-- b.journal -- 85 +include a.journal 86 +`) 87 + if len(rj.FileErrors()) == 0 { 88 + t.Fatal("expected cycle error") 152 89 } 90 +} 153 91 154 - parent := []byte("include child.journal\n") 155 - if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { 156 - t.Fatalf("writing parent: %v", err) 157 - } 92 +func TestLoader_Resolve_repeatedInclude(t *testing.T) { 93 + rj := resolveTxtar(t, "parent.journal", ` 94 +-- parent.journal -- 95 +include shared.journal 96 +include shared.journal 158 97 159 - l := NewLoader() 160 - pf, err := l.Load(filepath.Join(dir, "parent.journal")) 161 - if err != nil { 162 - t.Fatalf("unexpected error: %v", err) 163 - } 164 - if len(pf.FileErrors) > 0 { 165 - t.Fatalf("unexpected file errors: %v", pf.FileErrors) 98 +-- shared.journal -- 99 +account expenses:shared 100 +`) 101 + if len(rj.Occurrences) != 3 { 102 + t.Fatalf("expected 3 occurrences, got %d", len(rj.Occurrences)) 166 103 } 167 - if len(pf.Includes) != 1 { 168 - t.Fatalf("expected 1 include, got %d", len(pf.Includes)) 104 + if len(rj.ByPath["shared.journal"]) != 2 { 105 + t.Fatalf("expected 2 ByPath entries for shared.journal, got %d", 106 + len(rj.ByPath["shared.journal"])) 169 107 } 170 - included := pf.Includes[0] 171 - if included.Path != filepath.Join(dir, "child.journal") { 172 - t.Fatalf("expected child path, got %q", included.Path) 108 +} 109 + 110 +func TestLoader_ResolveBytes_parseErrors(t *testing.T) { 111 + rj := NewLoader().ResolveBytes("bad.journal", []byte("@@@ garbage\n")) 112 + if len(rj.Occurrences[0].Errors) == 0 { 113 + t.Fatal("expected parse errors for garbage input") 173 114 } 174 115 } 175 116 176 -func TestLoader_Load_withGlobInclude(t *testing.T) { 117 +func TestLoader_ContentCache(t *testing.T) { 177 118 dir := t.TempDir() 178 - sub := filepath.Join(dir, "data") 179 - os.MkdirAll(sub, 0o755) 119 + fpath := filepath.Join(dir, "a.journal") 180 120 181 - for _, name := range []string{"a.journal", "b.journal", "c.journal"} { 182 - if err := os.WriteFile(filepath.Join(sub, name), []byte("account expenses:"+name[:1]+"\n"), 0o644); err != nil { 183 - t.Fatalf("writing %s: %v", name, err) 184 - } 185 - } 121 + os.WriteFile(fpath, []byte("2024/01/15 t\n a $1\n"), 0o644) 122 + l := NewLoader() 186 123 187 - parent := []byte("include data/*.journal\n") 188 - if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { 189 - t.Fatalf("writing parent: %v", err) 124 + // first resolve populates cache. 125 + rj1 := mustResolve(t, l, fpath) 126 + date1 := txDate(rj1) 127 + 128 + // modify file, resolve again - should return cached date. 129 + os.WriteFile(fpath, []byte("2024/06/01 t\n a $1\n"), 0o644) 130 + rj2 := mustResolve(t, l, fpath) 131 + if txDate(rj2) != date1 { 132 + t.Fatal("expected cached date before invalidation") 190 133 } 191 134 192 - l := NewLoader() 193 - pf, err := l.Load(filepath.Join(dir, "parent.journal")) 194 - if err != nil { 195 - t.Fatalf("unexpected error: %v", err) 196 - } 197 - if len(pf.FileErrors) > 0 { 198 - t.Fatalf("unexpected file errors: %v", pf.FileErrors) 199 - } 200 - if len(pf.Includes) != 3 { 201 - t.Fatalf("expected 3 includes, got %d", len(pf.Includes)) 135 + // invalidate and resolve again - should see new date. 136 + l.InvalidateFile(fpath) 137 + rj3 := mustResolve(t, l, fpath) 138 + if txDate(rj3) != "2024-06-01" { 139 + t.Fatalf("expected 2024-06-01 after invalidation, got %s", txDate(rj3)) 202 140 } 203 141 } 204 142 205 -func TestLoader_Load_includeNotFound(t *testing.T) { 143 +func TestLoader_ContentCache_LineEndings(t *testing.T) { 206 144 dir := t.TempDir() 145 + fpath := filepath.Join(dir, "crlf.journal") 146 + os.WriteFile(fpath, []byte("2024/01/01 t\r\n a $1\r\n"), 0o644) 207 147 208 - parent := []byte("include data/*.journal\n") 209 - if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { 210 - t.Fatalf("writing parent: %v", err) 148 + rj, err := NewLoader().Resolve(fpath) 149 + if err != nil || len(rj.Occurrences[0].Errors) > 0 { 150 + t.Fatal("CRLF file should parse without errors") 211 151 } 152 +} 212 153 154 +func TestLoader_ClearCache(t *testing.T) { 213 155 l := NewLoader() 214 - pf, err := l.Load(filepath.Join(dir, "parent.journal")) 215 - if err != nil { 216 - t.Fatalf("unexpected error: %v", err) 217 - } 218 - if len(pf.FileErrors) != 1 { 219 - t.Fatalf("expected 1 file error, got %d", len(pf.FileErrors)) 156 + if len(l.contentCache) != 0 { 157 + t.Fatal("expected empty cache") 220 158 } 221 - if len(pf.Errors) != 0 { 222 - t.Fatalf("expected 0 parse errors, got %d", len(pf.Errors)) 159 + l.contentCache["x"] = []byte("y") 160 + l.ClearCache() 161 + if len(l.contentCache) != 0 { 162 + t.Fatal("expected empty cache after clear") 223 163 } 224 164 } 225 165 226 -func TestLoader_Load_cycleDedup(t *testing.T) { 227 - dir := t.TempDir() 166 +// helpers 228 167 229 - aPath := filepath.Join(dir, "a.journal") 230 - bPath := filepath.Join(dir, "b.journal") 231 - 232 - if err := os.WriteFile(aPath, []byte("include b.journal\n"), 0o644); err != nil { 233 - t.Fatalf("writing a: %v", err) 168 +func resolveTxtar(t *testing.T, rootFile, archive string) *ResolvedJournal { 169 + t.Helper() 170 + a := txtar.Parse([]byte(archive)) 171 + fsys, err := txtar.FS(a) 172 + if err != nil { 173 + t.Fatal(err) 234 174 } 235 - if err := os.WriteFile(bPath, []byte("include a.journal\n"), 0o644); err != nil { 236 - t.Fatalf("writing b: %v", err) 237 - } 238 - 239 175 l := NewLoader() 240 - pf, err := l.Load(aPath) 176 + rj, err := l.ResolveFS(fsys, rootFile) 241 177 if err != nil { 242 - t.Fatalf("unexpected error: %v", err) 243 - } 244 - // circular A→B→A: A is deduped (already stored before includes resolved) 245 - if len(pf.Includes) != 1 { 246 - t.Fatalf("expected 1 include, got %d", len(pf.Includes)) 178 + t.Fatal(err) 247 179 } 248 - if pf.Includes[0].Path != bPath { 249 - t.Fatalf("expected include path %q, got %q", bPath, pf.Includes[0].Path) 250 - } 251 - // B's include of A should resolve to the same pf (dedup) 252 - if pf.Includes[0].Includes[0] != pf { 253 - t.Fatal("expected circular dedup: B's include of A should point to original A") 254 - } 180 + return rj 255 181 } 256 182 257 -func TestLoader_Ordered(t *testing.T) { 258 - dir := t.TempDir() 259 - 260 - leaf := []byte("2024/01/01 t\n a $1\n") 261 - if err := os.WriteFile(filepath.Join(dir, "leaf.journal"), leaf, 0o644); err != nil { 262 - t.Fatalf("writing leaf: %v", err) 263 - } 264 - 265 - middle := []byte("include leaf.journal\n") 266 - if err := os.WriteFile(filepath.Join(dir, "middle.journal"), middle, 0o644); err != nil { 267 - t.Fatalf("writing middle: %v", err) 268 - } 269 - 270 - root := []byte("include middle.journal\n") 271 - if err := os.WriteFile(filepath.Join(dir, "root.journal"), root, 0o644); err != nil { 272 - t.Fatalf("writing root: %v", err) 273 - } 274 - 275 - l := NewLoader() 276 - pf, err := l.Load(filepath.Join(dir, "root.journal")) 183 +func mustResolve(t *testing.T, l *Loader, fpath string) *ResolvedJournal { 184 + t.Helper() 185 + rj, err := l.Resolve(fpath) 277 186 if err != nil { 278 - t.Fatalf("unexpected error: %v", err) 279 - } 280 - if len(pf.FileErrors) > 0 { 281 - t.Fatalf("unexpected file errors: %v", pf.FileErrors) 187 + t.Fatal(err) 282 188 } 283 - 284 - ordered := l.Ordered() 285 - names := make([]string, len(ordered)) 286 - for i, f := range ordered { 287 - names[i] = filepath.Base(f.Path) 288 - } 289 - 290 - // leaf before middle before root — includes before includer 291 - if len(names) != 3 { 292 - t.Fatalf("expected 3 ordered files, got %d: %v", len(names), names) 293 - } 294 - if names[0] != "leaf.journal" { 295 - t.Fatalf("expected leaf first, got %q", names[0]) 296 - } 297 - if names[1] != "middle.journal" { 298 - t.Fatalf("expected middle second, got %q", names[1]) 299 - } 300 - if names[2] != "root.journal" { 301 - t.Fatalf("expected root last, got %q", names[2]) 302 - } 189 + return rj 303 190 } 304 191 305 -func TestLoader_Load_fileErrorsSeparateFromParseErrors(t *testing.T) { 306 - l := NewLoader() 307 - src := []byte("@@@ garbage\ninclude nonexistent.journal\n") 308 - pf, err := l.LoadBytes("mixed.journal", src) 309 - if err != nil { 310 - t.Fatalf("unexpected error: %v", err) 311 - } 312 - if len(pf.Errors) == 0 { 313 - t.Fatal("expected parse errors") 314 - } 315 - if len(pf.FileErrors) == 0 { 316 - t.Fatal("expected file errors") 317 - } 192 +func txDate(rj *ResolvedJournal) string { 193 + tx := rj.Occurrences[0].Ast.Entries[0].(*ast.Transaction) 194 + return fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day) 318 195 }
M
journal/parser/benchmark_test.go
··· 5 5 "testing" 6 6 7 7 "olexsmir.xyz/clerk/journal/lexer" 8 - "olexsmir.xyz/clerk/journal/token" 9 8 ) 10 9 11 -func loadTestdata(path string) []byte { 12 - src, err := os.ReadFile(path) 10 +func BenchmarkParser(b *testing.B) { 11 + b.Run("basic", bench("../../journal/testdata/journals/basic.journal")) 12 + b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal")) 13 + b.Run("sample", bench("../../journal/testdata/journals/actual-sample.journal")) 14 + b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat")) 15 + b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal")) 16 +} 17 + 18 +func bench(fpath string) func(b *testing.B) { 19 + src, err := os.ReadFile(fpath) 13 20 if err != nil { 14 21 panic("loadTestdata: " + err.Error()) 15 22 } 16 - return src 17 -} 18 23 19 -var ( 20 - basicSrc = loadTestdata("../../journal/testdata/journals/basic.journal") 21 - personalSrc = loadTestdata("../../journal/testdata/journals/actual-personal.journal") 22 - sampleSrc = loadTestdata("../../journal/testdata/journals/actual-sample.journal") 23 - standardSrc = loadTestdata("../../journal/testdata/journals/actual-ledger-input-standard.dat") 24 - wowSrc = loadTestdata("../../journal/testdata/journals/actual-ledger-input-wow.dat") 25 - benchmarkSrc = loadTestdata("../../journal/testdata/journals/actual-1ktxns-100accts.journal") 26 -) 27 - 28 -func BenchmarkLexer_Basic(b *testing.B) { 29 - for b.Loop() { 30 - l := lexer.New("bench", basicSrc) 31 - for l.Next().Type != token.EOF { 24 + return func(b *testing.B) { 25 + b.ResetTimer() 26 + b.ReportAllocs() 27 + for b.Loop() { 28 + l := lexer.New("bench", src) 29 + New(l).ParseJournal() 32 30 } 33 31 } 34 32 } 35 - 36 -func BenchmarkLexer_Personal(b *testing.B) { 37 - for b.Loop() { 38 - l := lexer.New("bench", personalSrc) 39 - for l.Next().Type != token.EOF { 40 - } 41 - } 42 -} 43 - 44 -func BenchmarkLexer_Sample(b *testing.B) { 45 - for b.Loop() { 46 - l := lexer.New("bench", sampleSrc) 47 - for l.Next().Type != token.EOF { 48 - } 49 - } 50 -} 51 - 52 -func BenchmarkLexer_Standard(b *testing.B) { 53 - for b.Loop() { 54 - l := lexer.New("bench", standardSrc) 55 - for l.Next().Type != token.EOF { 56 - } 57 - } 58 -} 59 - 60 -func BenchmarkLexer_Wow(b *testing.B) { 61 - for b.Loop() { 62 - l := lexer.New("bench", wowSrc) 63 - for l.Next().Type != token.EOF { 64 - } 65 - } 66 -} 67 - 68 -func BenchmarkLexer_1kTxns(b *testing.B) { 69 - for b.Loop() { 70 - l := lexer.New("bench", benchmarkSrc) 71 - for l.Next().Type != token.EOF { 72 - } 73 - } 74 -} 75 - 76 -func BenchmarkParser_Basic(b *testing.B) { 77 - for b.Loop() { 78 - l := lexer.New("bench", basicSrc) 79 - New(l).ParseJournal() 80 - } 81 -} 82 - 83 -func BenchmarkParser_Personal(b *testing.B) { 84 - for b.Loop() { 85 - l := lexer.New("bench", personalSrc) 86 - New(l).ParseJournal() 87 - } 88 -} 89 - 90 -func BenchmarkParser_Sample(b *testing.B) { 91 - for b.Loop() { 92 - l := lexer.New("bench", sampleSrc) 93 - New(l).ParseJournal() 94 - } 95 -} 96 - 97 -func BenchmarkParser_Standard(b *testing.B) { 98 - for b.Loop() { 99 - l := lexer.New("bench", standardSrc) 100 - New(l).ParseJournal() 101 - } 102 -} 103 - 104 -func BenchmarkParser_Wow(b *testing.B) { 105 - for b.Loop() { 106 - l := lexer.New("bench", wowSrc) 107 - New(l).ParseJournal() 108 - } 109 -} 110 - 111 -func BenchmarkParser_1kTxns(b *testing.B) { 112 - for b.Loop() { 113 - l := lexer.New("bench", benchmarkSrc) 114 - New(l).ParseJournal() 115 - } 116 -} 117 - 118 -func BenchmarkParser_1kTxns_Allocs(b *testing.B) { 119 - b.ReportAllocs() 120 - for b.Loop() { 121 - l := lexer.New("bench", benchmarkSrc) 122 - New(l).ParseJournal() 123 - } 124 -} 125 - 126 -func BenchmarkParser_Parallel_1kTxns(b *testing.B) { 127 - b.RunParallel(func(pb *testing.PB) { 128 - for pb.Next() { 129 - l := lexer.New("bench", benchmarkSrc) 130 - New(l).ParseJournal() 131 - } 132 - }) 133 -}
M
journal/parser/parser.go
··· 16 16 errors []*ast.ParseError 17 17 cur token.Token 18 18 peek token.Token 19 + 20 + defaultYear int // set by year directive, used for short date inference 19 21 } 20 22 21 23 func New(lex *lexer.Lexer) *Parser { 22 24 p := &Parser{lexer: lex} 25 + p.advance() // populate .peek 26 + p.advance() // populate .cur 27 + return p 28 +} 29 + 30 +func NewWithYear(lex *lexer.Lexer, year int) *Parser { 31 + p := &Parser{lexer: lex, defaultYear: year} 23 32 p.advance() // populate .peek 24 33 p.advance() // populate .cur 25 34 return p ··· 519 528 520 529 if p.got(token.INT) { 521 530 year.Year, _ = strconv.Atoi(p.cur.Literal) 531 + p.defaultYear = year.Year 522 532 p.advance() 523 533 } else { 524 534 p.errorf("expected year, got %s", p.cur.Type) ··· 528 538 year.Comment = p.parseOptInlineComment() 529 539 p.expectNewline() 530 540 year.Span = p.span(s) 541 + 531 542 return year 532 543 } 533 544 ··· 1100 1111 p.errorf("invalid day %d in %q", day, lit) 1101 1112 return ast.Date{Span: p.span(s)} 1102 1113 } 1103 - return ast.Date{Month: month, Day: day, Sep: sep, Span: p.span(s)} 1114 + return ast.Date{Year: p.defaultYear, Month: month, Day: day, Sep: sep, Span: p.span(s)} 1104 1115 } 1105 1116 1106 1117 if len(parts) != 3 {
M
journal/printer/printer_test.go
··· 14 14 for _, tname := range tests { 15 15 t.Run(tname, func(t *testing.T) { 16 16 a := golden.Read(t, tname) 17 - pf, err := journal.NewLoader().LoadBytes(tname+".journal", a.Get("input")) 18 - if err != nil { 19 - t.Fatal(err) 20 - } 17 + rj := journal.NewLoader().ResolveBytes(tname+".journal", a.Get("input")) 18 + pf := rj.Occurrences[0] 21 19 var b strings.Builder 22 20 if err := DefaultConfig.Fprint(&b, pf.Ast); err != nil { 23 21 t.Fatal(err) ··· 33 31 b.Run(tname, func(b *testing.B) { 34 32 b.ReportAllocs() 35 33 inp := golden.Read(b, tname).Get("input") 36 - pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp) 37 - if err != nil { 38 - b.Fatal(err) 39 - } 34 + rj := journal.NewLoader().ResolveBytes(tname+".journal", inp) 35 + pf := rj.Occurrences[0] 40 36 b.ResetTimer() 41 37 for i := 0; i < b.N; i++ { 42 38 var buf strings.Builder ··· 61 57 for tname, tt := range testsWithConfig { 62 58 t.Run(tname, func(t *testing.T) { 63 59 a := golden.Read(t, tname) 64 - pf, err := journal.NewLoader().LoadBytes(tname+".journal", a.Get("input")) 65 - if err != nil { 66 - t.Fatal(err) 67 - } 60 + rj := journal.NewLoader().ResolveBytes(tname+".journal", a.Get("input")) 61 + pf := rj.Occurrences[0] 68 62 var b strings.Builder 69 63 if err := tt.Fprint(&b, pf.Ast); err != nil { 70 64 t.Fatal(err) ··· 77 71 78 72 func BenchmarkPrinter_Config(b *testing.B) { 79 73 inp := golden.Read(b, "entries").Get("input") 80 - pf, err := journal.NewLoader().LoadBytes("entries.journal", inp) 81 - if err != nil { 82 - b.Fatal(err) 83 - } 74 + rj := journal.NewLoader().ResolveBytes("entries.journal", inp) 75 + pf := rj.Occurrences[0] 84 76 for name, cfg := range testsWithConfig { 85 77 b.Run(name, func(b *testing.B) { 86 78 b.ReportAllocs()