all repos

clerk @ 4ba8053f16f04927c6459c7b7ee1bddc49586d4e

missing tooling for ledger/hledger

clerk/journal/loader.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
journal: loader: use ring buffer for cache, 9 days ago
1
package journal
2
3
import (
4
	"bytes"
5
	"container/ring"
6
	"fmt"
7
	"io/fs"
8
	"os"
9
	"path/filepath"
10
	"slices"
11
	"strings"
12
	"sync"
13
14
	"olexsmir.xyz/clerk/journal/ast"
15
	"olexsmir.xyz/clerk/journal/lexer"
16
	"olexsmir.xyz/clerk/journal/parser"
17
)
18
19
type ParsedFile struct {
20
	Path       string
21
	Src        []byte // file content; token literals in Ast alias it, must NOT be mutated after parsing
22
	Ast        *ast.Journal
23
	FileErrors []*ast.FileError
24
	Errors     []*ast.ParseError
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
	Occurrences []*ParsedFile  // all occurrences in depth-first order
39
	Items       []ResolvedItem // flat stream, occur-order
40
	ByPath      map[string][]*ParsedFile
41
}
42
43
// FileErrors returns all file errors from all occurrences.
44
func (rj *ResolvedJournal) FileErrors() []*ast.FileError {
45
	var all []*ast.FileError
46
	for _, pf := range rj.Occurrences {
47
		all = append(all, pf.FileErrors...)
48
	}
49
	return all
50
}
51
52
// parseCacheMax bounds the number of parsed files retained by the loader.
53
const parseCacheMax = 64
54
55
type parseKey struct {
56
	canon, content string
57
	defaultYear    int
58
}
59
60
type parseEntry struct {
61
	src []byte
62
	ast *ast.Journal
63
}
64
65
// Loader parses a journal and its includes, caching both by canonical path.
66
type Loader struct {
67
	mu           sync.RWMutex
68
	contentCache map[string][]byte // canonical path: normalised content
69
	parseCache   map[parseKey]parseEntry
70
	parseRing    *ring.Ring // holds parseCache keys oldest first, so overflow evicts the east recently stored one.
71
72
	// ContentProvider, when set, is consulted before any disk read. It returns
73
	// the file's authoritative content and ok=true, or ok=false to fall back to
74
	// disk. The zero value (nil) keeps the loader disk-only.
75
	ContentProvider func(path string) ([]byte, bool)
76
}
77
78
func NewLoader() *Loader {
79
	return &Loader{
80
		contentCache: make(map[string][]byte),
81
		parseCache:   make(map[parseKey]parseEntry),
82
		parseRing:    ring.New(parseCacheMax),
83
	}
84
}
85
86
// Resolve performs a fresh include-aware parse of fpath, returning a flat
87
// occurrence view. Same file may appear multiple times when included from
88
// multiple sites.
89
func (l *Loader) Resolve(fpath string) (*ResolvedJournal, error) {
90
	src, err := l.readContent(fpath)
91
	if err != nil {
92
		return nil, err
93
	}
94
	return l.ResolveBytes(fpath, src), nil
95
}
96
97
// ResolveBytes is like [Loader.Resolve] but parses from a byte slice; includes resolved relative to fpath.
98
func (l *Loader) ResolveBytes(fpath string, src []byte) *ResolvedJournal {
99
	rj := &ResolvedJournal{
100
		ByPath: make(map[string][]*ParsedFile),
101
	}
102
	l.resolveOccurrence(rj, nil, fpath, src, 0, nil)
103
	return rj
104
}
105
106
// ResolveFiles resolves multiple entry files into one flat view.
107
func (l *Loader) ResolveFiles(paths []string) *ResolvedJournal {
108
	rj := &ResolvedJournal{
109
		ByPath: make(map[string][]*ParsedFile),
110
	}
111
	for _, p := range paths {
112
		src, err := l.readContent(p)
113
		if err != nil {
114
			continue
115
		}
116
		l.resolveOccurrence(rj, nil, p, src, 0, nil)
117
	}
118
	return rj
119
}
120
121
// ResolveFS loads a journal from [fs.FS] via temp dir.
122
func (l *Loader) ResolveFS(fsys fs.FS, fpath string) (*ResolvedJournal, error) {
123
	dir, err := os.MkdirTemp("", "clerk-loadfs-*")
124
	if err != nil {
125
		return nil, fmt.Errorf("creating temp dir: %w", err)
126
	}
127
	defer os.RemoveAll(dir)
128
129
	if cerr := os.CopyFS(dir, fsys); cerr != nil {
130
		return nil, fmt.Errorf("copying fs to temp dir: %w", cerr)
131
	}
132
133
	rj, err := l.Resolve(filepath.Join(dir, fpath))
134
	if err != nil {
135
		return nil, err
136
	}
137
138
	// remap temp dir paths to FS-relative paths for deterministic output
139
	l.remapFilePaths(rj, dir)
140
	return rj, nil
141
}
142
143
func (l *Loader) remapFilePaths(rj *ResolvedJournal, oldRoot string) {
144
	for _, pf := range rj.Occurrences {
145
		if rel, err := filepath.Rel(oldRoot, pf.Path); err == nil {
146
			pf.Path = filepath.ToSlash(rel)
147
		}
148
	}
149
150
	newByPath := make(map[string][]*ParsedFile, len(rj.ByPath))
151
	for oldPath, pfs := range rj.ByPath {
152
		rel, err := filepath.Rel(oldRoot, oldPath)
153
		newPath := oldPath
154
		if err == nil {
155
			newPath = filepath.ToSlash(rel)
156
		}
157
		newByPath[newPath] = pfs
158
	}
159
	rj.ByPath = newByPath
160
}
161
162
// InvalidateFile removes a file from the content cache.
163
func (l *Loader) InvalidateFile(fpath string) {
164
	canon := CanonicalPath(fpath)
165
	l.mu.Lock()
166
	delete(l.contentCache, canon)
167
	l.mu.Unlock()
168
}
169
170
// Evict drops a file's cached content and every parse cached for it.
171
func (l *Loader) Evict(fpath string) {
172
	canon := CanonicalPath(fpath)
173
	l.mu.Lock()
174
	defer l.mu.Unlock()
175
	delete(l.contentCache, canon)
176
	for k := range l.parseCache {
177
		if k.canon == canon {
178
			delete(l.parseCache, k)
179
		}
180
	}
181
}
182
183
// readContent reads a file, preferring the content provider, then the disk content cache.
184
func (l *Loader) readContent(fpath string) ([]byte, error) {
185
	if l.ContentProvider != nil {
186
		if content, ok := l.ContentProvider(fpath); ok {
187
			return normaliseNewlines(content), nil
188
		}
189
	}
190
191
	canon := CanonicalPath(fpath)
192
193
	l.mu.RLock()
194
	content, ok := l.contentCache[canon]
195
	l.mu.RUnlock()
196
	if ok {
197
		return content, nil
198
	}
199
200
	raw, err := os.ReadFile(fpath)
201
	if err != nil {
202
		return nil, err
203
	}
204
205
	content = normaliseNewlines(raw)
206
	l.mu.Lock()
207
	l.contentCache[canon] = content
208
	l.mu.Unlock()
209
	return content, nil
210
}
211
212
func normaliseNewlines(raw []byte) []byte {
213
	content := bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))
214
	return bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
215
}
216
217
// resolveOccurrence recursively parses one occurrence and its includes.
218
func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) {
219
	// cycle detection uses canonical paths to catch cycles through symlinks.
220
	canon := CanonicalPath(fpath)
221
	if slices.Contains(stack, canon) {
222
		if parent != nil {
223
			parent.FileErrors = append(parent.FileErrors, &ast.FileError{
224
				Path:    fpath,
225
				Message: fmt.Sprintf("include cycle: %s", strings.Join(append(stack, canon), " → ")),
226
			})
227
		}
228
		return
229
	}
230
231
	key := parseKey{canon: canon, content: string(src), defaultYear: defaultYear}
232
	entry, ok := l.parseLookup(key)
233
	if !ok {
234
		lex := lexer.New(fpath, src)
235
		par := parser.NewWithYear(lex, defaultYear)
236
		j := par.ParseJournal()
237
		entry = parseEntry{src: src, ast: j}
238
		l.parseStore(key, entry)
239
	}
240
241
	pf := &ParsedFile{
242
		Path:   fpath,
243
		Src:    entry.src,
244
		Ast:    entry.ast,
245
		Errors: entry.ast.Errors,
246
	}
247
	rj.Occurrences = append(rj.Occurrences, pf)
248
	rj.ByPath[fpath] = append(rj.ByPath[fpath], pf)
249
250
	currentYear := defaultYear
251
	for i, entry := range entry.ast.Entries {
252
		switch e := entry.(type) {
253
		case *ast.BlankLine:
254
			continue
255
256
		case *ast.IncludeDirective:
257
			rj.Items = append(rj.Items, ResolvedItem{
258
				Occurrence: pf,
259
				IsInclude:  true,
260
				EntryIndex: i,
261
			})
262
263
			incPath, err := resolveIncludePath(fpath, e.Path)
264
			if err != nil {
265
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
266
					Path:    e.Path,
267
					Span:    e.Span,
268
					Message: err.Error(),
269
				})
270
				continue
271
			}
272
273
			matches, err := filepath.Glob(incPath)
274
			if err != nil || len(matches) == 0 {
275
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
276
					Path:    incPath,
277
					Span:    e.Span,
278
					Message: fmt.Sprintf("include not found: %s", e.Path),
279
				})
280
				continue
281
			}
282
283
			for _, match := range matches {
284
				childSrc, err := l.readContent(match)
285
				if err != nil {
286
					pf.FileErrors = append(pf.FileErrors, &ast.FileError{
287
						Path:    match,
288
						Span:    e.Span,
289
						Message: err.Error(),
290
					})
291
					continue
292
				}
293
				l.resolveOccurrence(rj, pf, match, childSrc, currentYear, append(stack, canon))
294
			}
295
296
		default:
297
			rj.Items = append(rj.Items, ResolvedItem{Occurrence: pf, EntryIndex: i})
298
		}
299
300
		// track year directive for context propagation to child includes
301
		if yd, ok := entry.(*ast.YearDirective); ok && yd.Year > 0 {
302
			currentYear = yd.Year
303
		}
304
	}
305
}
306
307
func resolveIncludePath(parentPath, incPattern string) (string, error) {
308
	base := filepath.Clean(filepath.Dir(parentPath))
309
	target := filepath.Clean(filepath.Join(base, incPattern))
310
	if filepath.IsAbs(incPattern) {
311
		return target, nil
312
	}
313
314
	// reject includes that escape the parent directory, e.g. "../../other.journal"
315
	rel, err := filepath.Rel(base, target)
316
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
317
		return "", fmt.Errorf("path traversal: %s", incPattern)
318
	}
319
	return target, nil
320
}
321
322
// CanonicalPath resolves a path to its canonical form: absolute, symlinks evaluated, cleaned.
323
func CanonicalPath(path string) string {
324
	abs, err := filepath.Abs(path)
325
	if err != nil {
326
		return filepath.Clean(path)
327
	}
328
	canonical, err := filepath.EvalSymlinks(abs)
329
	if err != nil {
330
		return filepath.Clean(abs)
331
	}
332
	return filepath.Clean(canonical)
333
}
334
335
func (l *Loader) parseLookup(key parseKey) (parseEntry, bool) {
336
	l.mu.RLock()
337
	entry, ok := l.parseCache[key]
338
	l.mu.RUnlock()
339
	return entry, ok
340
}
341
342
func (l *Loader) parseStore(key parseKey, entry parseEntry) {
343
	l.mu.Lock()
344
	defer l.mu.Unlock()
345
	old, full := l.parseRing.Value.(parseKey)
346
	l.parseRing.Value = key
347
	l.parseRing = l.parseRing.Next()
348
	if full {
349
		delete(l.parseCache, old)
350
	}
351
	l.parseCache[key] = entry
352
}