all repos

clerk @ 17c786e

missing tooling for ledger/hledger

clerk/journal/loader.go (view raw)

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