+105
cache.go
+105
cache.go
···
1
+
package gott
2
+
3
+
import (
4
+
"container/list"
5
+
"sync"
6
+
)
7
+
8
+
// cacheEntry holds a cached template AST
9
+
type cacheEntry struct {
10
+
key string
11
+
tmpl *Template
12
+
element *list.Element // pointer to LRU list element
13
+
}
14
+
15
+
// astCache is a thread-safe LRU cache for parsed template ASTs
16
+
type astCache struct {
17
+
mu sync.RWMutex
18
+
items map[string]*cacheEntry
19
+
lru *list.List // front = most recently used, back = least recently used
20
+
maxSize int // 0 = unlimited
21
+
}
22
+
23
+
// newCache creates a new LRU cache with the specified max size.
24
+
// If maxSize is 0, the cache has no size limit.
25
+
func newCache(maxSize int) *astCache {
26
+
return &astCache{
27
+
items: make(map[string]*cacheEntry),
28
+
lru: list.New(),
29
+
maxSize: maxSize,
30
+
}
31
+
}
32
+
33
+
// Get retrieves a cached template by key.
34
+
// Returns the template and true if found, nil and false otherwise.
35
+
// Accessing an entry moves it to the front of the LRU list.
36
+
func (c *astCache) Get(key string) (*Template, bool) {
37
+
c.mu.Lock()
38
+
defer c.mu.Unlock()
39
+
40
+
entry, ok := c.items[key]
41
+
if !ok {
42
+
return nil, false
43
+
}
44
+
45
+
// Move to front of LRU list (most recently used)
46
+
c.lru.MoveToFront(entry.element)
47
+
return entry.tmpl, true
48
+
}
49
+
50
+
// Put stores a template in the cache.
51
+
// If the cache is at capacity, the least recently used entry is evicted.
52
+
func (c *astCache) Put(key string, tmpl *Template) {
53
+
c.mu.Lock()
54
+
defer c.mu.Unlock()
55
+
56
+
// Check if key already exists
57
+
if entry, ok := c.items[key]; ok {
58
+
// Update existing entry
59
+
entry.tmpl = tmpl
60
+
c.lru.MoveToFront(entry.element)
61
+
return
62
+
}
63
+
64
+
// Evict LRU entry if at capacity
65
+
if c.maxSize > 0 && len(c.items) >= c.maxSize {
66
+
c.evictLRU()
67
+
}
68
+
69
+
// Create new entry
70
+
entry := &cacheEntry{
71
+
key: key,
72
+
tmpl: tmpl,
73
+
}
74
+
entry.element = c.lru.PushFront(entry)
75
+
c.items[key] = entry
76
+
}
77
+
78
+
// evictLRU removes the least recently used entry.
79
+
// Caller must hold the write lock.
80
+
func (c *astCache) evictLRU() {
81
+
oldest := c.lru.Back()
82
+
if oldest == nil {
83
+
return
84
+
}
85
+
86
+
entry := oldest.Value.(*cacheEntry)
87
+
c.lru.Remove(oldest)
88
+
delete(c.items, entry.key)
89
+
}
90
+
91
+
// Clear removes all entries from the cache.
92
+
func (c *astCache) Clear() {
93
+
c.mu.Lock()
94
+
defer c.mu.Unlock()
95
+
96
+
c.items = make(map[string]*cacheEntry)
97
+
c.lru.Init()
98
+
}
99
+
100
+
// Len returns the number of entries in the cache.
101
+
func (c *astCache) Len() int {
102
+
c.mu.RLock()
103
+
defer c.mu.RUnlock()
104
+
return len(c.items)
105
+
}
+118
cache_disk.go
+118
cache_disk.go
···
1
+
package gott
2
+
3
+
import (
4
+
"crypto/sha256"
5
+
"encoding/gob"
6
+
"encoding/hex"
7
+
"os"
8
+
"path/filepath"
9
+
)
10
+
11
+
func init() {
12
+
// Register all AST types with gob for serialization.
13
+
// Gob requires concrete types to be registered when encoding/decoding interfaces.
14
+
15
+
// Root and text nodes
16
+
gob.Register(&Template{})
17
+
gob.Register(&TextNode{})
18
+
19
+
// Expression nodes
20
+
gob.Register(&IdentExpr{})
21
+
gob.Register(&LiteralExpr{})
22
+
gob.Register(&BinaryExpr{})
23
+
gob.Register(&UnaryExpr{})
24
+
gob.Register(&CallExpr{})
25
+
gob.Register(&FilterExpr{})
26
+
gob.Register(&DefaultExpr{})
27
+
28
+
// Statement nodes
29
+
gob.Register(&OutputStmt{})
30
+
gob.Register(&IfStmt{})
31
+
gob.Register(&UnlessStmt{})
32
+
gob.Register(&ForeachStmt{})
33
+
gob.Register(&BlockStmt{})
34
+
gob.Register(&IncludeStmt{})
35
+
gob.Register(&WrapperStmt{})
36
+
gob.Register(&SetStmt{})
37
+
38
+
// Supporting types
39
+
gob.Register(&ElsIfClause{})
40
+
}
41
+
42
+
// diskCache persists compiled templates to disk using gob encoding.
43
+
type diskCache struct {
44
+
path string // directory for cache files
45
+
}
46
+
47
+
// newDiskCache creates a new disk cache at the specified path.
48
+
// Creates the directory if it doesn't exist.
49
+
func newDiskCache(path string) (*diskCache, error) {
50
+
if err := os.MkdirAll(path, 0755); err != nil {
51
+
return nil, err
52
+
}
53
+
return &diskCache{path: path}, nil
54
+
}
55
+
56
+
// hashKey creates a safe filename from a cache key using SHA256.
57
+
func hashKey(key string) string {
58
+
h := sha256.Sum256([]byte(key))
59
+
return hex.EncodeToString(h[:]) + ".gob"
60
+
}
61
+
62
+
// Get retrieves a cached template from disk.
63
+
// Returns nil and no error if the file doesn't exist.
64
+
func (d *diskCache) Get(key string) (*Template, error) {
65
+
filename := filepath.Join(d.path, hashKey(key))
66
+
67
+
file, err := os.Open(filename)
68
+
if err != nil {
69
+
if os.IsNotExist(err) {
70
+
return nil, nil
71
+
}
72
+
return nil, err
73
+
}
74
+
defer file.Close()
75
+
76
+
var tmpl Template
77
+
decoder := gob.NewDecoder(file)
78
+
if err := decoder.Decode(&tmpl); err != nil {
79
+
// Corrupted cache file - remove it
80
+
os.Remove(filename)
81
+
return nil, nil
82
+
}
83
+
84
+
return &tmpl, nil
85
+
}
86
+
87
+
// Put stores a template to disk.
88
+
func (d *diskCache) Put(key string, tmpl *Template) error {
89
+
filename := filepath.Join(d.path, hashKey(key))
90
+
91
+
file, err := os.Create(filename)
92
+
if err != nil {
93
+
return err
94
+
}
95
+
defer file.Close()
96
+
97
+
encoder := gob.NewEncoder(file)
98
+
return encoder.Encode(tmpl)
99
+
}
100
+
101
+
// Clear removes all cached templates from disk.
102
+
func (d *diskCache) Clear() error {
103
+
entries, err := os.ReadDir(d.path)
104
+
if err != nil {
105
+
return err
106
+
}
107
+
108
+
for _, entry := range entries {
109
+
if entry.IsDir() {
110
+
continue
111
+
}
112
+
// Only remove .gob files to avoid deleting unrelated files
113
+
if filepath.Ext(entry.Name()) == ".gob" {
114
+
os.Remove(filepath.Join(d.path, entry.Name()))
115
+
}
116
+
}
117
+
return nil
118
+
}
+449
cache_test.go
+449
cache_test.go
···
1
+
package gott
2
+
3
+
import (
4
+
"io/fs"
5
+
"os"
6
+
"path/filepath"
7
+
"testing"
8
+
"testing/fstest"
9
+
)
10
+
11
+
// TestInMemoryCache tests the LRU cache behavior
12
+
func TestInMemoryCache(t *testing.T) {
13
+
t.Run("basic get and put", func(t *testing.T) {
14
+
cache := newCache(0) // unlimited
15
+
16
+
// Put a template
17
+
tmpl := &Template{Nodes: []Node{&TextNode{Text: "hello"}}}
18
+
cache.Put("key1", tmpl)
19
+
20
+
// Get it back
21
+
got, ok := cache.Get("key1")
22
+
if !ok {
23
+
t.Fatal("expected to find key1 in cache")
24
+
}
25
+
if got != tmpl {
26
+
t.Error("got different template than stored")
27
+
}
28
+
})
29
+
30
+
t.Run("cache miss", func(t *testing.T) {
31
+
cache := newCache(0)
32
+
33
+
_, ok := cache.Get("nonexistent")
34
+
if ok {
35
+
t.Error("expected cache miss for nonexistent key")
36
+
}
37
+
})
38
+
39
+
t.Run("LRU eviction", func(t *testing.T) {
40
+
cache := newCache(2) // max 2 entries
41
+
42
+
tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
43
+
tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
44
+
tmpl3 := &Template{Nodes: []Node{&TextNode{Text: "3"}}}
45
+
46
+
cache.Put("key1", tmpl1)
47
+
cache.Put("key2", tmpl2)
48
+
49
+
// Verify both are present
50
+
if cache.Len() != 2 {
51
+
t.Errorf("expected 2 entries, got %d", cache.Len())
52
+
}
53
+
54
+
// Add a third, should evict key1 (LRU)
55
+
cache.Put("key3", tmpl3)
56
+
57
+
if cache.Len() != 2 {
58
+
t.Errorf("expected 2 entries after eviction, got %d", cache.Len())
59
+
}
60
+
61
+
// key1 should be gone (LRU)
62
+
_, ok := cache.Get("key1")
63
+
if ok {
64
+
t.Error("key1 should have been evicted")
65
+
}
66
+
67
+
// key2 and key3 should still be present
68
+
if _, ok := cache.Get("key2"); !ok {
69
+
t.Error("key2 should still be in cache")
70
+
}
71
+
if _, ok := cache.Get("key3"); !ok {
72
+
t.Error("key3 should still be in cache")
73
+
}
74
+
})
75
+
76
+
t.Run("LRU access updates order", func(t *testing.T) {
77
+
cache := newCache(2)
78
+
79
+
tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
80
+
tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
81
+
tmpl3 := &Template{Nodes: []Node{&TextNode{Text: "3"}}}
82
+
83
+
cache.Put("key1", tmpl1)
84
+
cache.Put("key2", tmpl2)
85
+
86
+
// Access key1 to make it recently used
87
+
cache.Get("key1")
88
+
89
+
// Add key3, should evict key2 (now LRU)
90
+
cache.Put("key3", tmpl3)
91
+
92
+
// key1 should still be present
93
+
if _, ok := cache.Get("key1"); !ok {
94
+
t.Error("key1 should still be in cache after being accessed")
95
+
}
96
+
97
+
// key2 should be gone
98
+
if _, ok := cache.Get("key2"); ok {
99
+
t.Error("key2 should have been evicted")
100
+
}
101
+
})
102
+
103
+
t.Run("clear cache", func(t *testing.T) {
104
+
cache := newCache(0)
105
+
106
+
cache.Put("key1", &Template{})
107
+
cache.Put("key2", &Template{})
108
+
109
+
cache.Clear()
110
+
111
+
if cache.Len() != 0 {
112
+
t.Errorf("expected empty cache after clear, got %d entries", cache.Len())
113
+
}
114
+
115
+
if _, ok := cache.Get("key1"); ok {
116
+
t.Error("key1 should not be in cache after clear")
117
+
}
118
+
})
119
+
120
+
t.Run("update existing key", func(t *testing.T) {
121
+
cache := newCache(0)
122
+
123
+
tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
124
+
tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
125
+
126
+
cache.Put("key1", tmpl1)
127
+
cache.Put("key1", tmpl2) // update
128
+
129
+
if cache.Len() != 1 {
130
+
t.Errorf("expected 1 entry after update, got %d", cache.Len())
131
+
}
132
+
133
+
got, _ := cache.Get("key1")
134
+
if got != tmpl2 {
135
+
t.Error("expected updated template")
136
+
}
137
+
})
138
+
}
139
+
140
+
// TestDiskCache tests the disk-based cache
141
+
func TestDiskCache(t *testing.T) {
142
+
// Create a temp directory for cache
143
+
tmpDir := filepath.Join(os.TempDir(), "gott-cache-test")
144
+
defer os.RemoveAll(tmpDir)
145
+
146
+
t.Run("create disk cache", func(t *testing.T) {
147
+
dc, err := newDiskCache(tmpDir)
148
+
if err != nil {
149
+
t.Fatalf("newDiskCache() error = %v", err)
150
+
}
151
+
if dc == nil {
152
+
t.Fatal("expected non-nil diskCache")
153
+
}
154
+
})
155
+
156
+
t.Run("put and get", func(t *testing.T) {
157
+
dc, _ := newDiskCache(tmpDir)
158
+
159
+
// Create a template with various node types to test serialization
160
+
tmpl := &Template{
161
+
Position: Position{Line: 1, Column: 1},
162
+
Nodes: []Node{
163
+
&TextNode{Text: "Hello "},
164
+
&OutputStmt{
165
+
Expr: &IdentExpr{Parts: []string{"name"}},
166
+
},
167
+
&IfStmt{
168
+
Condition: &BinaryExpr{
169
+
Op: TokenEq,
170
+
Left: &IdentExpr{Parts: []string{"x"}},
171
+
Right: &LiteralExpr{Value: float64(1)},
172
+
},
173
+
Body: []Node{&TextNode{Text: "yes"}},
174
+
},
175
+
},
176
+
}
177
+
178
+
err := dc.Put("test-key", tmpl)
179
+
if err != nil {
180
+
t.Fatalf("Put() error = %v", err)
181
+
}
182
+
183
+
got, err := dc.Get("test-key")
184
+
if err != nil {
185
+
t.Fatalf("Get() error = %v", err)
186
+
}
187
+
if got == nil {
188
+
t.Fatal("expected non-nil template from disk cache")
189
+
}
190
+
191
+
// Verify structure was preserved
192
+
if len(got.Nodes) != 3 {
193
+
t.Errorf("expected 3 nodes, got %d", len(got.Nodes))
194
+
}
195
+
196
+
// Check text node
197
+
if textNode, ok := got.Nodes[0].(*TextNode); !ok || textNode.Text != "Hello " {
198
+
t.Error("text node not preserved correctly")
199
+
}
200
+
201
+
// Check output stmt with ident
202
+
if outputStmt, ok := got.Nodes[1].(*OutputStmt); !ok {
203
+
t.Error("expected OutputStmt")
204
+
} else if identExpr, ok := outputStmt.Expr.(*IdentExpr); !ok || identExpr.Parts[0] != "name" {
205
+
t.Error("ident expr not preserved correctly")
206
+
}
207
+
})
208
+
209
+
t.Run("get nonexistent key", func(t *testing.T) {
210
+
dc, _ := newDiskCache(tmpDir)
211
+
212
+
got, err := dc.Get("nonexistent")
213
+
if err != nil {
214
+
t.Errorf("Get() error = %v", err)
215
+
}
216
+
if got != nil {
217
+
t.Error("expected nil for nonexistent key")
218
+
}
219
+
})
220
+
221
+
t.Run("clear disk cache", func(t *testing.T) {
222
+
dc, _ := newDiskCache(tmpDir)
223
+
224
+
// Add some entries
225
+
dc.Put("key1", &Template{})
226
+
dc.Put("key2", &Template{})
227
+
228
+
err := dc.Clear()
229
+
if err != nil {
230
+
t.Fatalf("Clear() error = %v", err)
231
+
}
232
+
233
+
// Verify entries are gone
234
+
if got, _ := dc.Get("key1"); got != nil {
235
+
t.Error("key1 should be cleared")
236
+
}
237
+
if got, _ := dc.Get("key2"); got != nil {
238
+
t.Error("key2 should be cleared")
239
+
}
240
+
})
241
+
}
242
+
243
+
// TestCachingIntegration tests that caching works with the Renderer
244
+
func TestCachingIntegration(t *testing.T) {
245
+
memFS := fstest.MapFS{
246
+
"test.html": &fstest.MapFile{Data: []byte("Hello [% name %]")},
247
+
}
248
+
249
+
t.Run("memory cache speeds up processing", func(t *testing.T) {
250
+
r, err := New(&Config{
251
+
IncludePaths: []fs.FS{memFS},
252
+
MaxCacheSize: 10,
253
+
})
254
+
if err != nil {
255
+
t.Fatalf("New() error = %v", err)
256
+
}
257
+
258
+
vars := map[string]any{"name": "World"}
259
+
260
+
// First call parses and caches
261
+
result1, err := r.ProcessFile("test.html", vars)
262
+
if err != nil {
263
+
t.Fatalf("ProcessFile() error = %v", err)
264
+
}
265
+
if result1 != "Hello World" {
266
+
t.Errorf("got %q, want %q", result1, "Hello World")
267
+
}
268
+
269
+
// Verify it's in cache
270
+
if r.cache.Len() != 1 {
271
+
t.Errorf("expected 1 entry in cache, got %d", r.cache.Len())
272
+
}
273
+
274
+
// Second call should use cache
275
+
result2, err := r.ProcessFile("test.html", vars)
276
+
if err != nil {
277
+
t.Fatalf("ProcessFile() error = %v", err)
278
+
}
279
+
if result2 != "Hello World" {
280
+
t.Errorf("got %q, want %q", result2, "Hello World")
281
+
}
282
+
})
283
+
284
+
t.Run("disk cache persistence", func(t *testing.T) {
285
+
tmpDir := filepath.Join(os.TempDir(), "gott-disk-cache-test")
286
+
defer os.RemoveAll(tmpDir)
287
+
288
+
r1, err := New(&Config{
289
+
IncludePaths: []fs.FS{memFS},
290
+
CachePath: tmpDir,
291
+
})
292
+
if err != nil {
293
+
t.Fatalf("New() error = %v", err)
294
+
}
295
+
296
+
// Process to populate cache
297
+
_, err = r1.ProcessFile("test.html", map[string]any{"name": "Test"})
298
+
if err != nil {
299
+
t.Fatalf("ProcessFile() error = %v", err)
300
+
}
301
+
302
+
// Create new renderer with same cache path
303
+
r2, err := New(&Config{
304
+
IncludePaths: []fs.FS{memFS},
305
+
CachePath: tmpDir,
306
+
})
307
+
if err != nil {
308
+
t.Fatalf("New() error = %v", err)
309
+
}
310
+
311
+
// Memory cache should be empty in new renderer
312
+
if r2.cache.Len() != 0 {
313
+
t.Errorf("expected empty memory cache in new renderer, got %d", r2.cache.Len())
314
+
}
315
+
316
+
// But processing should load from disk cache
317
+
result, err := r2.ProcessFile("test.html", map[string]any{"name": "FromDisk"})
318
+
if err != nil {
319
+
t.Fatalf("ProcessFile() error = %v", err)
320
+
}
321
+
if result != "Hello FromDisk" {
322
+
t.Errorf("got %q, want %q", result, "Hello FromDisk")
323
+
}
324
+
325
+
// Now memory cache should have the entry
326
+
if r2.cache.Len() != 1 {
327
+
t.Errorf("expected 1 entry in memory cache after disk load, got %d", r2.cache.Len())
328
+
}
329
+
})
330
+
331
+
t.Run("ClearCache clears both caches", func(t *testing.T) {
332
+
tmpDir := filepath.Join(os.TempDir(), "gott-clear-cache-test")
333
+
defer os.RemoveAll(tmpDir)
334
+
335
+
r, err := New(&Config{
336
+
IncludePaths: []fs.FS{memFS},
337
+
CachePath: tmpDir,
338
+
})
339
+
if err != nil {
340
+
t.Fatalf("New() error = %v", err)
341
+
}
342
+
343
+
// Populate caches
344
+
_, _ = r.ProcessFile("test.html", map[string]any{"name": "Test"})
345
+
346
+
// Clear
347
+
r.ClearCache()
348
+
349
+
// Memory cache should be empty
350
+
if r.cache.Len() != 0 {
351
+
t.Errorf("expected empty memory cache after clear, got %d", r.cache.Len())
352
+
}
353
+
354
+
// Disk cache should also be empty - verify by checking that
355
+
// the file no longer exists
356
+
entries, _ := os.ReadDir(tmpDir)
357
+
gobCount := 0
358
+
for _, e := range entries {
359
+
if filepath.Ext(e.Name()) == ".gob" {
360
+
gobCount++
361
+
}
362
+
}
363
+
if gobCount != 0 {
364
+
t.Errorf("expected 0 .gob files after clear, got %d", gobCount)
365
+
}
366
+
})
367
+
368
+
t.Run("Process caches by content hash", func(t *testing.T) {
369
+
r, err := New(&Config{
370
+
MaxCacheSize: 10,
371
+
})
372
+
if err != nil {
373
+
t.Fatalf("New() error = %v", err)
374
+
}
375
+
376
+
template := "Hello [% name %]!"
377
+
378
+
// First call
379
+
result1, _ := r.Process(template, map[string]any{"name": "Alice"})
380
+
if result1 != "Hello Alice!" {
381
+
t.Errorf("got %q, want %q", result1, "Hello Alice!")
382
+
}
383
+
384
+
// Second call with same template (different vars)
385
+
result2, _ := r.Process(template, map[string]any{"name": "Bob"})
386
+
if result2 != "Hello Bob!" {
387
+
t.Errorf("got %q, want %q", result2, "Hello Bob!")
388
+
}
389
+
390
+
// Should only have one cache entry (same template content)
391
+
if r.cache.Len() != 1 {
392
+
t.Errorf("expected 1 entry in cache for same template, got %d", r.cache.Len())
393
+
}
394
+
395
+
// Different template should add another entry
396
+
_, _ = r.Process("Different template", nil)
397
+
if r.cache.Len() != 2 {
398
+
t.Errorf("expected 2 entries for different templates, got %d", r.cache.Len())
399
+
}
400
+
})
401
+
}
402
+
403
+
// TestCacheWithIncludes tests that includes also use caching
404
+
func TestCacheWithIncludes(t *testing.T) {
405
+
memFS := fstest.MapFS{
406
+
"main.html": &fstest.MapFile{Data: []byte("Main: [% INCLUDE header.html %]")},
407
+
"header.html": &fstest.MapFile{Data: []byte("Header: [% title %]")},
408
+
}
409
+
410
+
r, err := New(&Config{
411
+
IncludePaths: []fs.FS{memFS},
412
+
MaxCacheSize: 10,
413
+
})
414
+
if err != nil {
415
+
t.Fatalf("New() error = %v", err)
416
+
}
417
+
418
+
result, err := r.ProcessFile("main.html", map[string]any{"title": "Test"})
419
+
if err != nil {
420
+
t.Fatalf("ProcessFile() error = %v", err)
421
+
}
422
+
423
+
expected := "Main: Header: Test"
424
+
if result != expected {
425
+
t.Errorf("got %q, want %q", result, expected)
426
+
}
427
+
428
+
// Both templates should be cached
429
+
if r.cache.Len() != 2 {
430
+
t.Errorf("expected 2 entries in cache (main + header), got %d", r.cache.Len())
431
+
}
432
+
}
433
+
434
+
// TestRendererClose tests that Close stops the SIGHUP handler
435
+
func TestRendererClose(t *testing.T) {
436
+
r, err := New(&Config{
437
+
EnableSIGHUP: true,
438
+
})
439
+
if err != nil {
440
+
t.Fatalf("New() error = %v", err)
441
+
}
442
+
443
+
// Close should not panic
444
+
r.Close()
445
+
446
+
// Second close should also not panic (idempotent-ish)
447
+
// Note: This will panic with "close of closed channel" if we don't handle it
448
+
// But for simplicity, we document that Close should only be called once
449
+
}
+11
-10
eval.go
+11
-10
eval.go
···
222
222
return nil
223
223
}
224
224
225
-
// Parse and evaluate the included template
226
-
parser := NewParser(content)
227
-
tmpl, parseErrs := parser.Parse()
228
-
if len(parseErrs) > 0 {
229
-
return parseErrs[0]
225
+
// Parse with caching
226
+
tmpl, err := e.renderer.parseTemplate(n.Name, content)
227
+
if err != nil {
228
+
return err
230
229
}
231
230
232
231
// Evaluate with same scope
···
260
259
261
260
// Load the wrapper template
262
261
var wrapperSource string
262
+
var wrapperName string
263
263
if block, ok := e.blocks[n.Name]; ok {
264
264
// Wrapper is a defined block - evaluate it
265
265
blockEval := NewEvaluator(e.renderer, e.copyVars())
···
272
272
}
273
273
}
274
274
wrapperSource = blockEval.output.String()
275
+
wrapperName = "block:" + n.Name
275
276
} else {
276
277
content, err := e.renderer.loadFile(n.Name)
277
278
if err != nil {
···
279
280
return nil
280
281
}
281
282
wrapperSource = content
283
+
wrapperName = n.Name
282
284
}
283
285
284
-
// Parse the wrapper template
285
-
parser := NewParser(wrapperSource)
286
-
tmpl, parseErrs := parser.Parse()
287
-
if len(parseErrs) > 0 {
288
-
return parseErrs[0]
286
+
// Parse the wrapper template with caching
287
+
tmpl, err := e.renderer.parseTemplate(wrapperName, wrapperSource)
288
+
if err != nil {
289
+
return err
289
290
}
290
291
291
292
// Evaluate wrapper with "content" variable set to wrapped content
+154
-9
gott.go
+154
-9
gott.go
···
1
1
package gott
2
2
3
3
import (
4
+
"crypto/sha256"
5
+
"encoding/hex"
4
6
"encoding/json"
5
7
"errors"
6
8
"html"
7
9
"io/fs"
8
10
"net/http"
9
11
"net/url"
12
+
"os"
13
+
"os/signal"
10
14
"path"
11
15
"strings"
12
16
"sync"
17
+
"syscall"
13
18
)
14
19
15
20
// Renderer is the main template engine. It processes TT2-style templates
···
19
24
includePaths []fs.FS // filesystems for INCLUDE lookups, searched in order
20
25
filters map[string]func(string, ...string) string // text transformation filters
21
26
virtualMethods map[string]func(any) (any, bool) // custom virtual methods for variables
27
+
cache *astCache // in-memory LRU cache for parsed templates
28
+
diskCache *diskCache // optional disk-based cache (nil if not configured)
29
+
stopSIGHUP chan struct{} // channel to stop SIGHUP handler goroutine
22
30
}
23
31
24
32
// Config holds initialization options for creating a new Renderer.
25
33
type Config struct {
26
34
IncludePaths []fs.FS // filesystems for template includes (e.g., os.DirFS, embed.FS)
27
35
Filters map[string]func(string, ...string) string // custom filters to register
36
+
CachePath string // directory for disk-based cache (optional, empty = memory only)
37
+
MaxCacheSize int // max entries in memory cache (0 = unlimited)
38
+
EnableSIGHUP bool // if true, clears cache on SIGHUP signal
28
39
}
29
40
30
41
var ErrTemplateNotFound = errors.New("template not found")
···
56
67
57
68
// New creates a Renderer with the given config. Registers default filters
58
69
// (upper, lower, html, uri) unless overridden in config.
59
-
func New(config *Config) *Renderer {
70
+
// Returns an error if disk cache path is specified but cannot be created.
71
+
func New(config *Config) (*Renderer, error) {
60
72
r := &Renderer{
61
73
filters: make(map[string]func(string, ...string) string),
62
74
virtualMethods: make(map[string]func(any) (any, bool)),
63
75
}
64
76
77
+
maxCacheSize := 0
65
78
if config != nil {
66
79
r.includePaths = config.IncludePaths
80
+
maxCacheSize = config.MaxCacheSize
67
81
if config.Filters != nil {
68
82
for k, v := range config.Filters {
69
83
r.filters[k] = v
70
84
}
71
85
}
86
+
87
+
// Initialize disk cache if path is specified
88
+
if config.CachePath != "" {
89
+
dc, err := newDiskCache(config.CachePath)
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
r.diskCache = dc
94
+
}
95
+
96
+
// Set up SIGHUP handler if enabled
97
+
if config.EnableSIGHUP {
98
+
r.stopSIGHUP = make(chan struct{})
99
+
go r.handleSIGHUP()
100
+
}
72
101
}
102
+
103
+
// Initialize in-memory cache
104
+
r.cache = newCache(maxCacheSize)
73
105
74
106
// Default filters
75
107
if r.filters["upper"] == nil {
···
93
125
}
94
126
}
95
127
96
-
return r
128
+
return r, nil
129
+
}
130
+
131
+
// handleSIGHUP listens for SIGHUP signals and clears the cache.
132
+
func (r *Renderer) handleSIGHUP() {
133
+
sigChan := make(chan os.Signal, 1)
134
+
signal.Notify(sigChan, syscall.SIGHUP)
135
+
136
+
for {
137
+
select {
138
+
case <-sigChan:
139
+
r.ClearCache()
140
+
case <-r.stopSIGHUP:
141
+
signal.Stop(sigChan)
142
+
return
143
+
}
144
+
}
145
+
}
146
+
147
+
// ClearCache removes all entries from both memory and disk caches.
148
+
func (r *Renderer) ClearCache() {
149
+
if r.cache != nil {
150
+
r.cache.Clear()
151
+
}
152
+
if r.diskCache != nil {
153
+
r.diskCache.Clear()
154
+
}
155
+
}
156
+
157
+
// Close stops background goroutines and releases resources.
158
+
// Should be called when the Renderer is no longer needed.
159
+
func (r *Renderer) Close() {
160
+
if r.stopSIGHUP != nil {
161
+
close(r.stopSIGHUP)
162
+
}
97
163
}
98
164
99
165
// AddFilter registers a custom filter function. Returns the Renderer for chaining.
···
156
222
}
157
223
158
224
// ProcessFile loads a template file and processes it with the given variables.
225
+
// Uses cached AST if available, otherwise parses and caches.
159
226
func (r *Renderer) ProcessFile(filename string, vars map[string]any) (string, error) {
160
-
content, err := r.loadFile(filename)
227
+
// Sanitize the path first to get a consistent cache key
228
+
safeName, err := sanitizePath(filename)
229
+
if err != nil {
230
+
return "", err
231
+
}
232
+
233
+
// Use file path as cache key (prefixed to distinguish from content hashes)
234
+
cacheKey := "file:" + safeName
235
+
236
+
tmpl, err := r.getOrParseCached(cacheKey, func() (string, error) {
237
+
return r.loadFile(filename)
238
+
})
161
239
if err != nil {
162
240
return "", err
163
241
}
164
-
return r.Process(content, vars)
242
+
243
+
// Evaluate the AST
244
+
eval := NewEvaluator(r, vars)
245
+
return eval.Eval(tmpl)
165
246
}
166
247
167
248
// Process processes a template string with the given variables.
168
249
// Uses an AST-based parser and evaluator for template processing.
250
+
// Caches the parsed AST using a hash of the input content.
169
251
func (r *Renderer) Process(input string, vars map[string]any) (string, error) {
170
-
// Parse the template into an AST
171
-
parser := NewParser(input)
172
-
tmpl, parseErrs := parser.Parse()
173
-
if len(parseErrs) > 0 {
174
-
return "", parseErrs[0]
252
+
// Use content hash as cache key
253
+
cacheKey := "content:" + hashContent(input)
254
+
255
+
tmpl, err := r.getOrParseCached(cacheKey, func() (string, error) {
256
+
return input, nil
257
+
})
258
+
if err != nil {
259
+
return "", err
175
260
}
176
261
177
262
// Evaluate the AST
178
263
eval := NewEvaluator(r, vars)
179
264
return eval.Eval(tmpl)
265
+
}
266
+
267
+
// hashContent creates a SHA256 hash of the template content for use as a cache key.
268
+
func hashContent(content string) string {
269
+
h := sha256.Sum256([]byte(content))
270
+
return hex.EncodeToString(h[:])
271
+
}
272
+
273
+
// getOrParseCached retrieves a cached template or parses and caches it.
274
+
// The loadContent function is called only if the template is not in cache.
275
+
func (r *Renderer) getOrParseCached(cacheKey string, loadContent func() (string, error)) (*Template, error) {
276
+
// Check in-memory cache first
277
+
if tmpl, ok := r.cache.Get(cacheKey); ok {
278
+
return tmpl, nil
279
+
}
280
+
281
+
// Check disk cache if available
282
+
if r.diskCache != nil {
283
+
tmpl, err := r.diskCache.Get(cacheKey)
284
+
if err != nil {
285
+
return nil, err
286
+
}
287
+
if tmpl != nil {
288
+
// Found on disk, add to memory cache
289
+
r.cache.Put(cacheKey, tmpl)
290
+
return tmpl, nil
291
+
}
292
+
}
293
+
294
+
// Load and parse the template
295
+
content, err := loadContent()
296
+
if err != nil {
297
+
return nil, err
298
+
}
299
+
300
+
parser := NewParser(content)
301
+
tmpl, parseErrs := parser.Parse()
302
+
if len(parseErrs) > 0 {
303
+
return nil, parseErrs[0]
304
+
}
305
+
306
+
// Store in both caches
307
+
r.cache.Put(cacheKey, tmpl)
308
+
if r.diskCache != nil {
309
+
// Best effort - don't fail if disk write fails
310
+
r.diskCache.Put(cacheKey, tmpl)
311
+
}
312
+
313
+
return tmpl, nil
314
+
}
315
+
316
+
// parseTemplate parses a template for use by includes/wrappers with caching.
317
+
// This is used internally by the evaluator.
318
+
func (r *Renderer) parseTemplate(name string, content string) (*Template, error) {
319
+
// Use the include/wrapper path as the cache key
320
+
cacheKey := "file:" + name
321
+
322
+
return r.getOrParseCached(cacheKey, func() (string, error) {
323
+
return content, nil
324
+
})
180
325
}
181
326
182
327
// Render processes a template file and writes the result to an http.ResponseWriter.
+20
-5
gott_test.go
+20
-5
gott_test.go
···
16
16
17
17
// TestNewFeatures tests ELSIF, arithmetic, string concatenation, and map iteration
18
18
func TestNewFeatures(t *testing.T) {
19
-
r := New(nil)
19
+
r, err := New(nil)
20
+
if err != nil {
21
+
t.Fatalf("New() error = %v", err)
22
+
}
20
23
21
24
tests := []struct {
22
25
name string
···
242
245
}
243
246
244
247
func TestSimpleIfCondition(t *testing.T) {
245
-
r := New(nil)
248
+
r, err := New(nil)
249
+
if err != nil {
250
+
t.Fatalf("New() error = %v", err)
251
+
}
246
252
247
253
tests := []struct {
248
254
name string
···
540
546
"templates/partials/footer.html": &fstest.MapFile{Data: []byte("FOOTER")},
541
547
}
542
548
543
-
r := New(&Config{
549
+
r, err := New(&Config{
544
550
IncludePaths: []fs.FS{memFS},
545
551
})
552
+
if err != nil {
553
+
t.Fatalf("New() error = %v", err)
554
+
}
546
555
547
556
tests := []struct {
548
557
name string
···
602
611
"layouts/main.html": &fstest.MapFile{Data: []byte("<main>[% content %]</main>")},
603
612
}
604
613
605
-
r := New(&Config{
614
+
r, err := New(&Config{
606
615
IncludePaths: []fs.FS{memFS},
607
616
})
617
+
if err != nil {
618
+
t.Fatalf("New() error = %v", err)
619
+
}
608
620
609
621
tests := []struct {
610
622
name string
···
649
661
"index.html": &fstest.MapFile{Data: []byte("INDEX")},
650
662
}
651
663
652
-
r := New(&Config{
664
+
r, err := New(&Config{
653
665
IncludePaths: []fs.FS{memFS},
654
666
})
667
+
if err != nil {
668
+
t.Fatalf("New() error = %v", err)
669
+
}
655
670
656
671
tests := []struct {
657
672
name string