all repos

clerk @ 1f3a17349e37c8189adb11b8d956acbf3b2080d1

missing tooling for ledger/hledger

clerk/journal/loader.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
improve loader, 1 day 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
21
	Ast        *ast.Journal
22
	Includes   []*ParsedFile
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
	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
42
}
43
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...)
49
	}
50
	return all
51
}
52
53
// Loader include-aware journal parsing caching.
54
type Loader struct {
55
	mu           sync.RWMutex
56
	contentCache map[string][]byte // canonical path: normalised content
57
}
58
59
func NewLoader() *Loader {
60
	return &Loader{contentCache: make(map[string][]byte)}
61
}
62
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
72
}
73
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
84
}
85
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)
93
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))
99
	if err != nil {
100
		return nil, err
101
	}
102
103
	// remap temp dir paths to FS-relative paths for deterministic output
104
	l.remapFilePaths(rj, dir)
105
	return rj, nil
106
}
107
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)
113
		}
114
	}
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)
122
		}
123
		newByPath[newPath] = pfs
124
	}
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()
134
}
135
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()
141
}
142
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
152
	}
153
154
	raw, err := os.ReadFile(fpath)
155
	if err != nil {
156
		return nil, err
157
	}
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
167
}
168
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
			})
179
		}
180
		return
181
	}
182
183
	lex := lexer.New(fpath, src)
184
	par := parser.NewWithYear(lex, defaultYear)
185
	j := par.ParseJournal()
186
187
	pf := &ParsedFile{
188
		Path:     fpath,
189
		Src:      src,
190
		Ast:      j,
191
		Includes: []*ParsedFile{},
192
		Errors:   j.Errors,
193
	}
194
	rj.Occurrences = append(rj.Occurrences, pf)
195
	rj.ByPath[fpath] = append(rj.ByPath[fpath], pf)
196
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:
205
			continue
206
207
		case *ast.IncludeDirective:
208
			rj.Items = append(rj.Items, ResolvedItem{
209
				Occurrence: pf,
210
				IsInclude:  true,
211
				EntryIndex: i,
212
			})
213
214
			incPath, err := resolveIncludePath(fpath, e.Path)
215
			if err != nil {
216
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
217
					Path:    e.Path,
218
					Span:    e.Span,
219
					Message: err.Error(),
220
				})
221
				continue
222
			}
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
254
		}
255
	}
256
}
257
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)
281
}