all repos

clerk @ ed6cfe6

missing tooling for ledger/hledger

clerk/journal/loader.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lexer: reduce allocations by replacing string() to unsafe.String() and..., 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
// readContent reads a file, preferring the content provider, then the disk content cache.
168
func (l *Loader) readContent(fpath string) ([]byte, error) {
169
	if l.ContentProvider != nil {
170
		if content, ok := l.ContentProvider(fpath); ok {
171
			return normaliseNewlines(content), nil
172
		}
173
	}
174
175
	canon := CanonicalPath(fpath)
176
177
	l.mu.RLock()
178
	content, ok := l.contentCache[canon]
179
	l.mu.RUnlock()
180
	if ok {
181
		return content, nil
182
	}
183
184
	raw, err := os.ReadFile(fpath)
185
	if err != nil {
186
		return nil, err
187
	}
188
189
	content = normaliseNewlines(raw)
190
	l.mu.Lock()
191
	l.contentCache[canon] = content
192
	l.mu.Unlock()
193
	return content, nil
194
}
195
196
func normaliseNewlines(raw []byte) []byte {
197
	content := bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))
198
	return bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
199
}
200
201
// resolveOccurrence recursively parses one occurrence and its includes
202
func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) {
203
	// cycle detection uses canonical paths to catch cycles through symlinks.
204
	canon := CanonicalPath(fpath)
205
	if slices.Contains(stack, canon) {
206
		if parent != nil {
207
			parent.FileErrors = append(parent.FileErrors, &ast.FileError{
208
				Path:    fpath,
209
				Message: fmt.Sprintf("include cycle: %s", strings.Join(append(stack, canon), " → ")),
210
			})
211
		}
212
		return
213
	}
214
215
	key := parseKey{canon: canon, content: string(src), defaultYear: defaultYear}
216
	entry, ok := l.parseLookup(key)
217
	if !ok {
218
		lex := lexer.New(fpath, src)
219
		par := parser.NewWithYear(lex, defaultYear)
220
		j := par.ParseJournal()
221
		entry = parseEntry{src: src, ast: j}
222
		l.parseStore(key, entry)
223
	}
224
225
	pf := &ParsedFile{
226
		Path:   fpath,
227
		Src:    entry.src,
228
		Ast:    entry.ast,
229
		Errors: entry.ast.Errors,
230
	}
231
	rj.Occurrences = append(rj.Occurrences, pf)
232
	rj.ByPath[fpath] = append(rj.ByPath[fpath], pf)
233
234
	currentYear := defaultYear
235
	for i, entry := range entry.ast.Entries {
236
		switch e := entry.(type) {
237
		case *ast.BlankLine:
238
			continue
239
240
		case *ast.IncludeDirective:
241
			rj.Items = append(rj.Items, ResolvedItem{
242
				Occurrence: pf,
243
				IsInclude:  true,
244
				EntryIndex: i,
245
			})
246
247
			incPath, err := resolveIncludePath(fpath, e.Path)
248
			if err != nil {
249
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
250
					Path:    e.Path,
251
					Span:    e.Span,
252
					Message: err.Error(),
253
				})
254
				continue
255
			}
256
257
			matches, err := filepath.Glob(incPath)
258
			if err != nil || len(matches) == 0 {
259
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
260
					Path:    incPath,
261
					Span:    e.Span,
262
					Message: fmt.Sprintf("include not found: %s", e.Path),
263
				})
264
				continue
265
			}
266
267
			for _, match := range matches {
268
				childSrc, err := l.readContent(match)
269
				if err != nil {
270
					pf.FileErrors = append(pf.FileErrors, &ast.FileError{
271
						Path:    match,
272
						Span:    e.Span,
273
						Message: err.Error(),
274
					})
275
					continue
276
				}
277
				l.resolveOccurrence(rj, pf, match, childSrc, currentYear, append(stack, canon))
278
			}
279
280
		default:
281
			rj.Items = append(rj.Items, ResolvedItem{Occurrence: pf, EntryIndex: i})
282
		}
283
284
		// track year directive for context propagation to child includes
285
		if yd, ok := entry.(*ast.YearDirective); ok && yd.Year > 0 {
286
			currentYear = yd.Year
287
		}
288
	}
289
}
290
291
func resolveIncludePath(parentPath, incPattern string) (string, error) {
292
	base := filepath.Clean(filepath.Dir(parentPath))
293
	target := filepath.Clean(filepath.Join(base, incPattern))
294
	if filepath.IsAbs(incPattern) {
295
		return target, nil
296
	}
297
298
	// reject includes that escape the parent directory, e.g. "../../other.journal"
299
	rel, err := filepath.Rel(base, target)
300
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
301
		return "", fmt.Errorf("path traversal: %s", incPattern)
302
	}
303
	return target, nil
304
}
305
306
// CanonicalPath resolvea path to it's canonical form: absolute, symlinks evaluated, cleaned.
307
func CanonicalPath(path string) string {
308
	abs, err := filepath.Abs(path)
309
	if err != nil {
310
		return filepath.Clean(path)
311
	}
312
	canonical, err := filepath.EvalSymlinks(abs)
313
	if err != nil {
314
		return filepath.Clean(abs)
315
	}
316
	return filepath.Clean(canonical)
317
}
318
319
func (l *Loader) parseLookup(key parseKey) (parseEntry, bool) {
320
	l.mu.RLock()
321
	entry, ok := l.parseCache[key]
322
	l.mu.RUnlock()
323
	return entry, ok
324
}
325
326
func (l *Loader) parseStore(key parseKey, entry parseEntry) {
327
	l.mu.Lock()
328
	l.parseCache[key] = entry
329
	if len(l.parseCache) > parseCacheMax {
330
		for k := range l.parseCache {
331
			delete(l.parseCache, k)
332
			break
333
		}
334
	}
335
	l.mu.Unlock()
336
}