A go template renderer based on Perl's Template Toolkit

feat: add AST caching for faster template rendering

Add in-memory LRU cache and optional disk-based cache for compiled
template ASTs. This avoids repeated parsing of the same templates.

New Config options:
- CachePath: directory for disk-based gob cache
- MaxCacheSize: max entries in memory cache (0 = unlimited)
- EnableSIGHUP: clear cache on SIGHUP signal

Breaking change: New() now returns (*Renderer, error)

+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
··· 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
··· 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
··· 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
··· 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
··· 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