all repos

clerk @ c2e8832

missing tooling for ledger/hledger

clerk/journal/loader.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: cache per-document analyses with include-aware invalidation..., 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
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
// ResolveFS loads a journal from [fs.FS] via temp dir.
104
func (l *Loader) ResolveFS(fsys fs.FS, fpath string) (*ResolvedJournal, error) {
105
	dir, err := os.MkdirTemp("", "clerk-loadfs-*")
106
	if err != nil {
107
		return nil, fmt.Errorf("creating temp dir: %w", err)
108
	}
109
	defer os.RemoveAll(dir)
110
111
	if cerr := os.CopyFS(dir, fsys); cerr != nil {
112
		return nil, fmt.Errorf("copying fs to temp dir: %w", cerr)
113
	}
114
115
	rj, err := l.Resolve(filepath.Join(dir, fpath))
116
	if err != nil {
117
		return nil, err
118
	}
119
120
	// remap temp dir paths to FS-relative paths for deterministic output
121
	l.remapFilePaths(rj, dir)
122
	return rj, nil
123
}
124
125
func (l *Loader) remapFilePaths(rj *ResolvedJournal, oldRoot string) {
126
	for _, pf := range rj.Occurrences {
127
		if rel, err := filepath.Rel(oldRoot, pf.Path); err == nil {
128
			pf.Path = filepath.ToSlash(rel)
129
		}
130
	}
131
132
	newByPath := make(map[string][]*ParsedFile, len(rj.ByPath))
133
	for oldPath, pfs := range rj.ByPath {
134
		rel, err := filepath.Rel(oldRoot, oldPath)
135
		newPath := oldPath
136
		if err == nil {
137
			newPath = filepath.ToSlash(rel)
138
		}
139
		newByPath[newPath] = pfs
140
	}
141
	rj.ByPath = newByPath
142
}
143
144
// InvalidateFile removes a file from the content cache
145
func (l *Loader) InvalidateFile(fpath string) {
146
	canon := CanonicalPath(fpath)
147
	l.mu.Lock()
148
	delete(l.contentCache, canon)
149
	l.mu.Unlock()
150
}
151
152
// readContent reads a file, preferring the content provider, then the disk content cache.
153
func (l *Loader) readContent(fpath string) ([]byte, error) {
154
	if l.ContentProvider != nil {
155
		if content, ok := l.ContentProvider(fpath); ok {
156
			return normaliseNewlines(content), nil
157
		}
158
	}
159
160
	canon := CanonicalPath(fpath)
161
162
	l.mu.RLock()
163
	content, ok := l.contentCache[canon]
164
	l.mu.RUnlock()
165
	if ok {
166
		return content, nil
167
	}
168
169
	raw, err := os.ReadFile(fpath)
170
	if err != nil {
171
		return nil, err
172
	}
173
174
	content = normaliseNewlines(raw)
175
	l.mu.Lock()
176
	l.contentCache[canon] = content
177
	l.mu.Unlock()
178
	return content, nil
179
}
180
181
func normaliseNewlines(raw []byte) []byte {
182
	content := bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))
183
	return bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
184
}
185
186
// resolveOccurrence recursively parses one occurrence and its includes
187
func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) {
188
	// cycle detection uses canonical paths to catch cycles through symlinks.
189
	canon := CanonicalPath(fpath)
190
	if slices.Contains(stack, canon) {
191
		if parent != nil {
192
			parent.FileErrors = append(parent.FileErrors, &ast.FileError{
193
				Path:    fpath,
194
				Message: fmt.Sprintf("include cycle: %s", strings.Join(append(stack, canon), " → ")),
195
			})
196
		}
197
		return
198
	}
199
200
	key := parseKey{canon: canon, content: string(src), defaultYear: defaultYear}
201
	entry, ok := l.parseLookup(key)
202
	if !ok {
203
		lex := lexer.New(fpath, src)
204
		par := parser.NewWithYear(lex, defaultYear)
205
		j := par.ParseJournal()
206
		entry = parseEntry{src: src, ast: j}
207
		l.parseStore(key, entry)
208
	}
209
210
	pf := &ParsedFile{
211
		Path:   fpath,
212
		Src:    entry.src,
213
		Ast:    entry.ast,
214
		Errors: entry.ast.Errors,
215
	}
216
	rj.Occurrences = append(rj.Occurrences, pf)
217
	rj.ByPath[fpath] = append(rj.ByPath[fpath], pf)
218
219
	currentYear := defaultYear
220
	for i, entry := range entry.ast.Entries {
221
		switch e := entry.(type) {
222
		case *ast.BlankLine:
223
			continue
224
225
		case *ast.IncludeDirective:
226
			rj.Items = append(rj.Items, ResolvedItem{
227
				Occurrence: pf,
228
				IsInclude:  true,
229
				EntryIndex: i,
230
			})
231
232
			incPath, err := resolveIncludePath(fpath, e.Path)
233
			if err != nil {
234
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
235
					Path:    e.Path,
236
					Span:    e.Span,
237
					Message: err.Error(),
238
				})
239
				continue
240
			}
241
242
			matches, err := filepath.Glob(incPath)
243
			if err != nil || len(matches) == 0 {
244
				pf.FileErrors = append(pf.FileErrors, &ast.FileError{
245
					Path:    incPath,
246
					Span:    e.Span,
247
					Message: fmt.Sprintf("include not found: %s", e.Path),
248
				})
249
				continue
250
			}
251
252
			for _, match := range matches {
253
				childSrc, err := l.readContent(match)
254
				if err != nil {
255
					pf.FileErrors = append(pf.FileErrors, &ast.FileError{
256
						Path:    match,
257
						Span:    e.Span,
258
						Message: err.Error(),
259
					})
260
					continue
261
				}
262
				l.resolveOccurrence(rj, pf, match, childSrc, currentYear, append(stack, canon))
263
			}
264
265
		default:
266
			rj.Items = append(rj.Items, ResolvedItem{Occurrence: pf, EntryIndex: i})
267
		}
268
269
		// track year directive for context propagation to child includes
270
		if yd, ok := entry.(*ast.YearDirective); ok && yd.Year > 0 {
271
			currentYear = yd.Year
272
		}
273
	}
274
}
275
276
func resolveIncludePath(parentPath, incPattern string) (string, error) {
277
	base := filepath.Clean(filepath.Dir(parentPath))
278
	target := filepath.Clean(filepath.Join(base, incPattern))
279
	if filepath.IsAbs(incPattern) {
280
		return target, nil
281
	}
282
283
	// reject includes that escape the parent directory, e.g. "../../other.journal"
284
	rel, err := filepath.Rel(base, target)
285
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
286
		return "", fmt.Errorf("path traversal: %s", incPattern)
287
	}
288
	return target, nil
289
}
290
291
// CanonicalPath resolvea path to it's canonical form: absolute, symlinks evaluated, cleaned.
292
func CanonicalPath(path string) string {
293
	abs, err := filepath.Abs(path)
294
	if err != nil {
295
		return filepath.Clean(path)
296
	}
297
	canonical, err := filepath.EvalSymlinks(abs)
298
	if err != nil {
299
		return filepath.Clean(abs)
300
	}
301
	return filepath.Clean(canonical)
302
}
303
304
func (l *Loader) parseLookup(key parseKey) (parseEntry, bool) {
305
	l.mu.RLock()
306
	entry, ok := l.parseCache[key]
307
	l.mu.RUnlock()
308
	return entry, ok
309
}
310
311
func (l *Loader) parseStore(key parseKey, entry parseEntry) {
312
	l.mu.Lock()
313
	l.parseCache[key] = entry
314
	if len(l.parseCache) > parseCacheMax {
315
		for k := range l.parseCache {
316
			delete(l.parseCache, k)
317
			break
318
		}
319
	}
320
	l.mu.Unlock()
321
}