diff --git a/LICENSE b/LICENSE
index 86ea496..4361a17 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 semihalev
+Copyright (c) 2025 Yasar Semih Alev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/PROGRESS.md b/PROGRESS.md
index bfad71f..0a12d56 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -35,19 +35,40 @@
- Added `RegisterExtension()` method for registering extension bundles
- Created example application demonstrating custom extensions
+2. **Development Mode and Template Caching**
+ - Added `SetDevelopmentMode()` method that sets appropriate debug/cache settings
+ - Added `SetDebug()` and `SetCache()` methods for individual control
+ - Improved template loading to respect cache settings
+ - Templates are not cached when cache is disabled
+ - Added tests to verify caching behavior
+
+3. **Template Modification Checking**
+ - Added `TimestampAwareLoader` interface for loaders that support modification time checking
+ - Implemented modification time tracking in the `FileSystemLoader`
+ - Added cache invalidation based on file modification times
+ - Automatic template reloading when files change and auto-reload is enabled
+ - Updated Template struct to track the source loader and modification time
+ - Added comprehensive tests for file modification detection
+ - Added example application demonstrating auto-reload in action
+
+4. **Optimized Filter Chain Processing**
+ - Implemented a specialized filter chain detection and evaluation algorithm
+ - Added support for detecting and processing filter chains in a single pass
+ - Reduced the number of intermediate allocations for chained filters
+ - Improved performance for templates with multiple filters applied to the same value
+ - Added tests and benchmarks to verify correctness and performance gains
+
## Future Improvements
-1. **Optimize Filter Chain Processing**
- - Current implementation processes each filter individually, could optimize for common filter chains
-
-2. **More Tests**
+1. **More Tests**
- Add more comprehensive tests for edge cases
- - Add benchmarking tests
+ - Add more benchmarks for different template scenarios
-3. **Error Handling**
+2. **Error Handling**
- Improve error messages for filter-related issues
- Add better debugging support
-
-4. **Template Caching**
- - Implement a more robust template caching system
+
+3. **Template Compilation**
+ - Implement a compiled template format for even faster rendering
+ - Add the ability to pre-compile templates for production use
diff --git a/README.md b/README.md
index 3accbc4..94bdb31 100644
--- a/README.md
+++ b/README.md
@@ -227,12 +227,65 @@ engine.RegisterExtension("my_extension", func(ext *twig.CustomExtension) {
})
```
+## Development Mode and Caching
+
+Twig provides several options to control template caching and debug behavior:
+
+```go
+// Create a new Twig engine
+engine := twig.New()
+
+// Enable development mode (enables debug, enables auto-reload, disables caching)
+engine.SetDevelopmentMode(true)
+
+// Or control individual settings
+engine.SetDebug(true) // Enable debug mode
+engine.SetCache(false) // Disable template caching
+engine.SetAutoReload(true) // Enable template auto-reloading
+```
+
+### Development Mode
+
+When development mode is enabled:
+- Template caching is disabled, ensuring you always see the latest changes
+- Auto-reload is enabled, which will check for template modifications
+- Debug mode is enabled for more detailed error messages
+
+This is ideal during development to avoid having to restart your application when templates change.
+
+### Auto-Reload & Template Modification Checking
+
+The engine can automatically detect when template files change on disk and reload them:
+
+```go
+// Enable auto-reload to detect template changes
+engine.SetAutoReload(true)
+```
+
+When auto-reload is enabled:
+1. The engine tracks the last modification time of each template
+2. When a template is requested, it checks if the file has been modified
+3. If the file has changed, it automatically reloads the template
+4. If the file hasn't changed, it uses the cached version (if caching is enabled)
+
+This provides the best of both worlds:
+- Fast performance (no unnecessary file system access for unchanged templates)
+- Always up-to-date content (automatic reload when templates change)
+
+### Production Mode
+
+By default, Twig runs in production mode:
+- Template caching is enabled for maximum performance
+- Auto-reload is disabled to avoid unnecessary file system checks
+- Debug mode is disabled to reduce overhead
+
## Performance
The library is designed with performance in mind:
- Minimal memory allocations
- Efficient parsing and rendering
- Template caching
+- Production/development mode toggle
## License
diff --git a/examples/development_mode/main.go b/examples/development_mode/main.go
new file mode 100644
index 0000000..96da31c
--- /dev/null
+++ b/examples/development_mode/main.go
@@ -0,0 +1,155 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/semihalev/twig"
+)
+
+func main() {
+ // Create template directories
+ templatesDir := "./templates"
+ os.MkdirAll(templatesDir, 0755)
+
+ // Create a sample template file
+ templatePath := filepath.Join(templatesDir, "hello.twig")
+ templateContent := `
+
+
+
+ {{ title }}
+
+
+
Hello, {{ name }}!
+
Welcome to Twig in {{ mode }} mode.
+
+ {% if items %}
+
+ {% for item in items %}
+
{{ item }}
+ {% endfor %}
+
+ {% endif %}
+
+
+`
+ err := os.WriteFile(templatePath, []byte(templateContent), 0644)
+ if err != nil {
+ fmt.Printf("Error creating template: %v\n", err)
+ return
+ }
+ fmt.Println("Created template:", templatePath)
+
+ // Create a Twig engine in development mode
+ engine := twig.New()
+
+ // Enable development mode
+ engine.SetDevelopmentMode(true)
+ fmt.Println("Development mode enabled:")
+ fmt.Printf(" - Cache enabled: %v\n", engine.IsCacheEnabled())
+ fmt.Printf(" - Debug enabled: %v\n", engine.IsDebugEnabled())
+ fmt.Printf(" - AutoReload: %v\n", engine.IsAutoReloadEnabled())
+
+ // Register a template loader
+ loader := twig.NewFileSystemLoader([]string{templatesDir})
+ engine.RegisterLoader(loader)
+
+ // Context for rendering
+ context := map[string]interface{}{
+ "title": "Twig Development Mode Example",
+ "name": "Developer",
+ "mode": "development",
+ "items": []string{"Easy to use", "No template caching", "Auto-reloading enabled"},
+ }
+
+ // Render the template to stdout
+ fmt.Println("\n--- Rendering in Development Mode ---")
+ err = engine.RenderTo(os.Stdout, "hello.twig", context)
+ if err != nil {
+ fmt.Printf("Error rendering template: %v\n", err)
+ return
+ }
+
+ // Cached templates should be empty in development mode
+ fmt.Printf("\n\nNumber of cached templates: %d\n", engine.GetCachedTemplateCount())
+
+ // Now switch to production mode
+ engine.SetDevelopmentMode(false)
+ fmt.Println("\nProduction mode enabled:")
+ fmt.Printf(" - Cache enabled: %v\n", engine.IsCacheEnabled())
+ fmt.Printf(" - Debug enabled: %v\n", engine.IsDebugEnabled())
+ fmt.Printf(" - AutoReload: %v\n", engine.IsAutoReloadEnabled())
+
+ // Change the context
+ context["mode"] = "production"
+ context["items"] = []string{"Maximum performance", "Template caching", "Optimized for speed"}
+
+ // Render again in production mode
+ fmt.Println("\n--- Rendering in Production Mode ---")
+ err = engine.RenderTo(os.Stdout, "hello.twig", context)
+ if err != nil {
+ fmt.Printf("Error rendering template: %v\n", err)
+ return
+ }
+
+ // Now we should have templates in the cache
+ fmt.Printf("\n\nNumber of cached templates: %d\n", engine.GetCachedTemplateCount())
+ fmt.Println("Template names in cache:")
+ for _, name := range engine.GetCachedTemplateNames() {
+ fmt.Printf(" - %s\n", name)
+ }
+
+ fmt.Println("\nDemonstrating auto-reload with file modification:")
+
+ // Let's modify the template file
+ fmt.Println("Modifying template file...")
+ modifiedContent := `
+
+
+
+ {{ title }} - UPDATED
+
+
+
Hello, {{ name }}!
+
This template was modified while the application is running.
+
Welcome to Twig in {{ mode }} mode.
+
+ {% if items %}
+
+ {% for item in items %}
+
{{ item }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+`
+ err = os.WriteFile(templatePath, []byte(modifiedContent), 0644)
+ if err != nil {
+ fmt.Printf("Error modifying template: %v\n", err)
+ return
+ }
+
+ // Enable development mode
+ engine.SetDevelopmentMode(true)
+ context["title"] = "Twig Auto-Reload Example"
+
+ // Pause to make sure the file system registers the change
+ fmt.Println("Waiting for file system to register change...")
+ time.Sleep(1 * time.Second)
+
+ // Render the template again - it should load the modified version
+ fmt.Println("\n--- Rendering with Auto-Reload ---")
+ err = engine.RenderTo(os.Stdout, "hello.twig", context)
+ if err != nil {
+ fmt.Printf("Error rendering template: %v\n", err)
+ return
+ }
+
+ fmt.Println("\n\nSuccess! The template was automatically reloaded.")
+}
\ No newline at end of file
diff --git a/examples/development_mode/templates/hello.twig b/examples/development_mode/templates/hello.twig
new file mode 100644
index 0000000..9876866
--- /dev/null
+++ b/examples/development_mode/templates/hello.twig
@@ -0,0 +1,22 @@
+
+
+
+
+ {{ title }} - UPDATED
+
+
+
Hello, {{ name }}!
+
This template was modified while the application is running.
+
Welcome to Twig in {{ mode }} mode.
+
+ {% if items %}
+
+ {% for item in items %}
+
{{ item }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
diff --git a/loader.go b/loader.go
index a416bae..04d5faa 100644
--- a/loader.go
+++ b/loader.go
@@ -15,11 +15,21 @@ type Loader interface {
Exists(name string) bool
}
+// TimestampAwareLoader is an interface for loaders that can check modification times
+type TimestampAwareLoader interface {
+ Loader
+
+ // GetModifiedTime returns the last modification time of a template
+ GetModifiedTime(name string) (int64, error)
+}
+
// FileSystemLoader loads templates from the file system
type FileSystemLoader struct {
paths []string
suffix string
defaultPaths []string
+ // Stores paths for each loaded template to avoid repeatedly searching for the file
+ templatePaths map[string]string
}
// ArrayLoader loads templates from an in-memory array
@@ -52,11 +62,28 @@ func NewFileSystemLoader(paths []string) *FileSystemLoader {
paths: normalizedPaths,
suffix: ".twig",
defaultPaths: defaultPaths,
+ templatePaths: make(map[string]string),
}
}
// Load loads a template from the file system
func (l *FileSystemLoader) Load(name string) (string, error) {
+ // Check if we already know the location of this template
+ if filePath, ok := l.templatePaths[name]; ok {
+ // Check if file still exists at this path
+ if _, err := os.Stat(filePath); err == nil {
+ // Read file content
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", fmt.Errorf("error reading template %s: %w", name, err)
+ }
+
+ return string(content), nil
+ }
+ // If file doesn't exist anymore, remove from cache and search again
+ delete(l.templatePaths, name)
+ }
+
// Check each path for the template
for _, path := range l.paths {
filePath := filepath.Join(path, name)
@@ -68,6 +95,9 @@ func (l *FileSystemLoader) Load(name string) (string, error) {
// Check if file exists
if _, err := os.Stat(filePath); err == nil {
+ // Save the path for future lookups
+ l.templatePaths[name] = filePath
+
// Read file content
content, err := os.ReadFile(filePath)
if err != nil {
@@ -106,6 +136,44 @@ func (l *FileSystemLoader) SetSuffix(suffix string) {
l.suffix = suffix
}
+// GetModifiedTime returns the last modification time of a template file
+func (l *FileSystemLoader) GetModifiedTime(name string) (int64, error) {
+ // If we already know where this template is, check that path directly
+ if filePath, ok := l.templatePaths[name]; ok {
+ info, err := os.Stat(filePath)
+ if err != nil {
+ // If file doesn't exist anymore, remove from cache
+ if os.IsNotExist(err) {
+ delete(l.templatePaths, name)
+ }
+ return 0, err
+ }
+
+ return info.ModTime().Unix(), nil
+ }
+
+ // Otherwise search for the template
+ for _, path := range l.paths {
+ filePath := filepath.Join(path, name)
+
+ // Add suffix if not already present
+ if !hasSuffix(filePath, l.suffix) {
+ filePath = filePath + l.suffix
+ }
+
+ // Check if file exists
+ info, err := os.Stat(filePath)
+ if err == nil {
+ // Save the path for future lookups
+ l.templatePaths[name] = filePath
+
+ return info.ModTime().Unix(), nil
+ }
+ }
+
+ return 0, fmt.Errorf("%w: %s", ErrTemplateNotFound, name)
+}
+
// NewArrayLoader creates a new array loader
func NewArrayLoader(templates map[string]string) *ArrayLoader {
return &ArrayLoader{
diff --git a/render.go b/render.go
index b201961..28d57f4 100644
--- a/render.go
+++ b/render.go
@@ -1,3 +1,4 @@
+// Revert to backup file and use the content with our changes
package twig
import (
@@ -406,31 +407,8 @@ func (ctx *RenderContext) EvaluateExpression(node Node) (interface{}, error) {
return ctx.CallFunction(n.name, args)
case *FilterNode:
- // Evaluate the base value
- value, err := ctx.EvaluateExpression(n.node)
- if err != nil {
- return nil, err
- }
-
- // Evaluate filter arguments
- args := make([]interface{}, len(n.args))
- for i, arg := range n.args {
- val, err := ctx.EvaluateExpression(arg)
- if err != nil {
- return nil, err
- }
- args[i] = val
- }
-
- // Look for the filter in the environment
- if ctx.env != nil {
- if filter, ok := ctx.env.filters[n.filter]; ok {
- // Call the filter with the value and any arguments
- return filter(value, args...)
- }
- }
-
- return nil, fmt.Errorf("filter '%s' not found", n.filter)
+ // Use the optimized filter chain implementation from render_filter.go
+ return ctx.evaluateFilterNode(n)
case *TestNode:
// Evaluate the tested value
diff --git a/render.go.bak b/render.go.bak
new file mode 100644
index 0000000..b201961
--- /dev/null
+++ b/render.go.bak
@@ -0,0 +1,880 @@
+package twig
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// RenderContext holds the state during template rendering
+type RenderContext struct {
+ env *Environment
+ context map[string]interface{}
+ blocks map[string][]Node
+ macros map[string]Node
+ parent *RenderContext
+ engine *Engine // Reference to engine for loading templates
+ extending bool // Whether this template extends another
+ currentBlock *BlockNode // Current block being rendered (for parent() function)
+}
+
+// Error types
+var (
+ ErrTemplateNotFound = errors.New("template not found")
+ ErrUndefinedVar = errors.New("undefined variable")
+ ErrInvalidAttribute = errors.New("invalid attribute access")
+ ErrCompilation = errors.New("compilation error")
+ ErrRender = errors.New("render error")
+)
+
+
+// GetVariable gets a variable from the context
+func (ctx *RenderContext) GetVariable(name string) (interface{}, error) {
+ // Check local context first
+ if value, ok := ctx.context[name]; ok {
+ return value, nil
+ }
+
+ // Check globals
+ if ctx.env != nil {
+ if value, ok := ctx.env.globals[name]; ok {
+ return value, nil
+ }
+ }
+
+ // Check parent context
+ if ctx.parent != nil {
+ return ctx.parent.GetVariable(name)
+ }
+
+ // Return nil with no error for undefined variables
+ // Twig treats undefined variables as empty strings during rendering
+ return nil, nil
+}
+
+// SetVariable sets a variable in the context
+func (ctx *RenderContext) SetVariable(name string, value interface{}) {
+ ctx.context[name] = value
+}
+
+// GetMacro gets a macro from the context
+func (ctx *RenderContext) GetMacro(name string) (Node, bool) {
+ // Check local macros first
+ if macro, ok := ctx.macros[name]; ok {
+ return macro, true
+ }
+
+ // Check parent context
+ if ctx.parent != nil {
+ return ctx.parent.GetMacro(name)
+ }
+
+ return nil, false
+}
+
+// CallMacro calls a macro with the given arguments
+func (ctx *RenderContext) CallMacro(w io.Writer, name string, args []interface{}) error {
+ // Find the macro
+ macro, ok := ctx.GetMacro(name)
+ if !ok {
+ return fmt.Errorf("macro '%s' not found", name)
+ }
+
+ // Check if it's a MacroNode
+ macroNode, ok := macro.(*MacroNode)
+ if !ok {
+ return fmt.Errorf("'%s' is not a macro", name)
+ }
+
+ // Call the macro
+ return macroNode.Call(w, ctx, args)
+}
+
+// CallFunction calls a function with the given arguments
+func (ctx *RenderContext) CallFunction(name string, args []interface{}) (interface{}, error) {
+ // Check if it's a function in the environment
+ if ctx.env != nil {
+ if fn, ok := ctx.env.functions[name]; ok {
+ return fn(args...)
+ }
+ }
+
+ // Check if it's a built-in function
+ switch name {
+ case "range":
+ return ctx.callRangeFunction(args)
+ case "length", "count":
+ return ctx.callLengthFunction(args)
+ case "max":
+ return ctx.callMaxFunction(args)
+ case "min":
+ return ctx.callMinFunction(args)
+ }
+
+ // Check if it's a macro
+ if macro, ok := ctx.GetMacro(name); ok {
+ // Return a callable function
+ return func(w io.Writer) error {
+ macroNode, ok := macro.(*MacroNode)
+ if !ok {
+ return fmt.Errorf("'%s' is not a macro", name)
+ }
+ return macroNode.Call(w, ctx, args)
+ }, nil
+ }
+
+ return nil, fmt.Errorf("function '%s' not found", name)
+}
+
+// callRangeFunction implements the range function
+func (ctx *RenderContext) callRangeFunction(args []interface{}) (interface{}, error) {
+ if len(args) < 2 {
+ return nil, fmt.Errorf("range function requires at least 2 arguments")
+ }
+
+ // Get the start and end values
+ start, ok1 := ctx.toNumber(args[0])
+ end, ok2 := ctx.toNumber(args[1])
+
+ if !ok1 || !ok2 {
+ return nil, fmt.Errorf("range arguments must be numbers")
+ }
+
+ // Get the step value (default is 1)
+ step := 1.0
+ if len(args) > 2 {
+ if s, ok := ctx.toNumber(args[2]); ok {
+ step = s
+ }
+ }
+
+ // Create the range
+ var result []int
+ for i := start; i <= end; i += step {
+ result = append(result, int(i))
+ }
+
+ return result, nil
+}
+
+// callLengthFunction implements the length/count function
+func (ctx *RenderContext) callLengthFunction(args []interface{}) (interface{}, error) {
+ if len(args) != 1 {
+ return nil, fmt.Errorf("length/count function requires exactly 1 argument")
+ }
+
+ val := args[0]
+ v := reflect.ValueOf(val)
+
+ switch v.Kind() {
+ case reflect.String:
+ return len(v.String()), nil
+ case reflect.Slice, reflect.Array:
+ return v.Len(), nil
+ case reflect.Map:
+ return v.Len(), nil
+ default:
+ return 0, nil
+ }
+}
+
+// callMaxFunction implements the max function
+func (ctx *RenderContext) callMaxFunction(args []interface{}) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, fmt.Errorf("max function requires at least 1 argument")
+ }
+
+ // If the argument is a slice or array, find the max value in it
+ if len(args) == 1 {
+ v := reflect.ValueOf(args[0])
+ if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
+ if v.Len() == 0 {
+ return nil, nil
+ }
+
+ max := v.Index(0).Interface()
+ maxNum, ok := ctx.toNumber(max)
+ if !ok {
+ return max, nil
+ }
+
+ for i := 1; i < v.Len(); i++ {
+ val := v.Index(i).Interface()
+ if valNum, ok := ctx.toNumber(val); ok {
+ if valNum > maxNum {
+ max = val
+ maxNum = valNum
+ }
+ }
+ }
+
+ return max, nil
+ }
+ }
+
+ // Find the max value in the arguments
+ max := args[0]
+ maxNum, ok := ctx.toNumber(max)
+ if !ok {
+ return max, nil
+ }
+
+ for i := 1; i < len(args); i++ {
+ val := args[i]
+ if valNum, ok := ctx.toNumber(val); ok {
+ if valNum > maxNum {
+ max = val
+ maxNum = valNum
+ }
+ }
+ }
+
+ return max, nil
+}
+
+// callMinFunction implements the min function
+func (ctx *RenderContext) callMinFunction(args []interface{}) (interface{}, error) {
+ if len(args) < 1 {
+ return nil, fmt.Errorf("min function requires at least 1 argument")
+ }
+
+ // If the argument is a slice or array, find the min value in it
+ if len(args) == 1 {
+ v := reflect.ValueOf(args[0])
+ if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
+ if v.Len() == 0 {
+ return nil, nil
+ }
+
+ min := v.Index(0).Interface()
+ minNum, ok := ctx.toNumber(min)
+ if !ok {
+ return min, nil
+ }
+
+ for i := 1; i < v.Len(); i++ {
+ val := v.Index(i).Interface()
+ if valNum, ok := ctx.toNumber(val); ok {
+ if valNum < minNum {
+ min = val
+ minNum = valNum
+ }
+ }
+ }
+
+ return min, nil
+ }
+ }
+
+ // Find the min value in the arguments
+ min := args[0]
+ minNum, ok := ctx.toNumber(min)
+ if !ok {
+ return min, nil
+ }
+
+ for i := 1; i < len(args); i++ {
+ val := args[i]
+ if valNum, ok := ctx.toNumber(val); ok {
+ if valNum < minNum {
+ min = val
+ minNum = valNum
+ }
+ }
+ }
+
+ return min, nil
+}
+
+// EvaluateExpression evaluates an expression node
+func (ctx *RenderContext) EvaluateExpression(node Node) (interface{}, error) {
+ switch n := node.(type) {
+ case *LiteralNode:
+ return n.value, nil
+
+ case *VariableNode:
+ // Check if it's a macro first
+ if macro, ok := ctx.GetMacro(n.name); ok {
+ return macro, nil
+ }
+
+ // Otherwise, look up variable
+ return ctx.GetVariable(n.name)
+
+ case *GetAttrNode:
+ obj, err := ctx.EvaluateExpression(n.node)
+ if err != nil {
+ return nil, err
+ }
+
+ attrName, err := ctx.EvaluateExpression(n.attribute)
+ if err != nil {
+ return nil, err
+ }
+
+ attrStr, ok := attrName.(string)
+ if !ok {
+ return nil, fmt.Errorf("attribute name must be a string")
+ }
+
+ // Check if obj is a map containing macros (from import)
+ if moduleMap, ok := obj.(map[string]interface{}); ok {
+ if macro, ok := moduleMap[attrStr]; ok {
+ return macro, nil
+ }
+ }
+
+ return ctx.getAttribute(obj, attrStr)
+
+ case *BinaryNode:
+ left, err := ctx.EvaluateExpression(n.left)
+ if err != nil {
+ return nil, err
+ }
+
+ right, err := ctx.EvaluateExpression(n.right)
+ if err != nil {
+ return nil, err
+ }
+
+ return ctx.evaluateBinaryOp(n.operator, left, right)
+
+ case *ConditionalNode:
+ // Evaluate the condition
+ condResult, err := ctx.EvaluateExpression(n.condition)
+ if err != nil {
+ return nil, err
+ }
+
+ // If condition is true, evaluate the true expression, otherwise evaluate the false expression
+ if ctx.toBool(condResult) {
+ return ctx.EvaluateExpression(n.trueExpr)
+ } else {
+ return ctx.EvaluateExpression(n.falseExpr)
+ }
+
+ case *ArrayNode:
+ // Evaluate each item in the array
+ items := make([]interface{}, len(n.items))
+ for i, item := range n.items {
+ val, err := ctx.EvaluateExpression(item)
+ if err != nil {
+ return nil, err
+ }
+ items[i] = val
+ }
+ return items, nil
+
+ case *FunctionNode:
+ // Check if it's a macro call
+ if macro, ok := ctx.GetMacro(n.name); ok {
+ // Evaluate arguments
+ args := make([]interface{}, len(n.args))
+ for i, arg := range n.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return nil, err
+ }
+ args[i] = val
+ }
+
+ // Return a callable that can be rendered later
+ return func(w io.Writer) error {
+ macroNode, ok := macro.(*MacroNode)
+ if !ok {
+ return fmt.Errorf("'%s' is not a macro", n.name)
+ }
+ return macroNode.Call(w, ctx, args)
+ }, nil
+ }
+
+ // Otherwise, it's a regular function call
+ // Evaluate arguments
+ args := make([]interface{}, len(n.args))
+ for i, arg := range n.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return nil, err
+ }
+ args[i] = val
+ }
+
+ return ctx.CallFunction(n.name, args)
+
+ case *FilterNode:
+ // Evaluate the base value
+ value, err := ctx.EvaluateExpression(n.node)
+ if err != nil {
+ return nil, err
+ }
+
+ // Evaluate filter arguments
+ args := make([]interface{}, len(n.args))
+ for i, arg := range n.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return nil, err
+ }
+ args[i] = val
+ }
+
+ // Look for the filter in the environment
+ if ctx.env != nil {
+ if filter, ok := ctx.env.filters[n.filter]; ok {
+ // Call the filter with the value and any arguments
+ return filter(value, args...)
+ }
+ }
+
+ return nil, fmt.Errorf("filter '%s' not found", n.filter)
+
+ case *TestNode:
+ // Evaluate the tested value
+ value, err := ctx.EvaluateExpression(n.node)
+ if err != nil {
+ return nil, err
+ }
+
+ // Evaluate test arguments
+ args := make([]interface{}, len(n.args))
+ for i, arg := range n.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return nil, err
+ }
+ args[i] = val
+ }
+
+ // Look for the test in the environment
+ if ctx.env != nil {
+ if test, ok := ctx.env.tests[n.test]; ok {
+ // Call the test with the value and any arguments
+ return test(value, args...)
+ }
+ }
+
+ return false, fmt.Errorf("test '%s' not found", n.test)
+
+ case *UnaryNode:
+ // Evaluate the operand
+ operand, err := ctx.EvaluateExpression(n.node)
+ if err != nil {
+ return nil, err
+ }
+
+ // Apply the operator
+ switch n.operator {
+ case "not", "!":
+ return !ctx.toBool(operand), nil
+ case "+":
+ if num, ok := ctx.toNumber(operand); ok {
+ return num, nil
+ }
+ return 0, nil
+ case "-":
+ if num, ok := ctx.toNumber(operand); ok {
+ return -num, nil
+ }
+ return 0, nil
+ default:
+ return nil, fmt.Errorf("unsupported unary operator: %s", n.operator)
+ }
+
+ default:
+ return nil, fmt.Errorf("unsupported expression type: %T", node)
+ }
+}
+
+// getAttribute gets an attribute from an object
+func (ctx *RenderContext) getAttribute(obj interface{}, attr string) (interface{}, error) {
+ if obj == nil {
+ return nil, fmt.Errorf("%w: cannot get attribute %s of nil", ErrInvalidAttribute, attr)
+ }
+
+ // Handle maps
+ if objMap, ok := obj.(map[string]interface{}); ok {
+ if value, exists := objMap[attr]; exists {
+ return value, nil
+ }
+ return nil, fmt.Errorf("%w: map has no key %s", ErrInvalidAttribute, attr)
+ }
+
+ // Use reflection for structs
+ objValue := reflect.ValueOf(obj)
+
+ // Handle pointer indirection
+ if objValue.Kind() == reflect.Ptr {
+ objValue = objValue.Elem()
+ }
+
+ // Handle structs
+ if objValue.Kind() == reflect.Struct {
+ // Try field access first
+ field := objValue.FieldByName(attr)
+ if field.IsValid() && field.CanInterface() {
+ return field.Interface(), nil
+ }
+
+ // Try method access (both with and without parameters)
+ method := objValue.MethodByName(attr)
+ if method.IsValid() {
+ if method.Type().NumIn() == 0 {
+ results := method.Call(nil)
+ if len(results) > 0 {
+ return results[0].Interface(), nil
+ }
+ return nil, nil
+ }
+ }
+
+ // Try method on pointer to struct
+ ptrValue := reflect.New(objValue.Type())
+ ptrValue.Elem().Set(objValue)
+ method = ptrValue.MethodByName(attr)
+ if method.IsValid() {
+ if method.Type().NumIn() == 0 {
+ results := method.Call(nil)
+ if len(results) > 0 {
+ return results[0].Interface(), nil
+ }
+ return nil, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("%w: %s", ErrInvalidAttribute, attr)
+}
+
+// evaluateBinaryOp evaluates a binary operation
+func (ctx *RenderContext) evaluateBinaryOp(operator string, left, right interface{}) (interface{}, error) {
+ switch operator {
+ case "+":
+ // Handle string concatenation
+ if lStr, lok := left.(string); lok {
+ if rStr, rok := right.(string); rok {
+ return lStr + rStr, nil
+ }
+ return lStr + ctx.ToString(right), nil
+ }
+
+ // Handle numeric addition
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum + rNum, nil
+ }
+ }
+
+ case "-":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum - rNum, nil
+ }
+ }
+
+ case "*":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum * rNum, nil
+ }
+ }
+
+ case "/":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ if rNum == 0 {
+ return nil, errors.New("division by zero")
+ }
+ return lNum / rNum, nil
+ }
+ }
+
+ case "==":
+ return ctx.equals(left, right), nil
+
+ case "!=":
+ return !ctx.equals(left, right), nil
+
+ case "<":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum < rNum, nil
+ }
+ }
+
+ case ">":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum > rNum, nil
+ }
+ }
+
+ case "<=":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum <= rNum, nil
+ }
+ }
+
+ case ">=":
+ if lNum, lok := ctx.toNumber(left); lok {
+ if rNum, rok := ctx.toNumber(right); rok {
+ return lNum >= rNum, nil
+ }
+ }
+
+ case "and", "&&":
+ return ctx.toBool(left) && ctx.toBool(right), nil
+
+ case "or", "||":
+ return ctx.toBool(left) || ctx.toBool(right), nil
+
+ case "~":
+ // String concatenation
+ return ctx.ToString(left) + ctx.ToString(right), nil
+
+ case "in":
+ // Check if left is in right (for arrays, slices, maps, strings)
+ return ctx.contains(right, left)
+
+ case "not in":
+ // Check if left is not in right
+ contains, err := ctx.contains(right, left)
+ if err != nil {
+ return false, err
+ }
+ return !contains, nil
+
+ case "matches":
+ // Regular expression match
+ pattern := ctx.ToString(right)
+ str := ctx.ToString(left)
+
+ // Compile the regex
+ regex, err := regexp.Compile(pattern)
+ if err != nil {
+ return false, fmt.Errorf("invalid regular expression: %s", err)
+ }
+
+ return regex.MatchString(str), nil
+
+ case "starts with":
+ // String prefix check
+ str := ctx.ToString(left)
+ prefix := ctx.ToString(right)
+ return strings.HasPrefix(str, prefix), nil
+
+ case "ends with":
+ // String suffix check
+ str := ctx.ToString(left)
+ suffix := ctx.ToString(right)
+ return strings.HasSuffix(str, suffix), nil
+ }
+
+ return nil, fmt.Errorf("unsupported binary operator: %s", operator)
+}
+
+// contains checks if a value is contained in a container (string, slice, array, map)
+func (ctx *RenderContext) contains(container, item interface{}) (bool, error) {
+ if container == nil {
+ return false, nil
+ }
+
+ itemStr := ctx.ToString(item)
+
+ // Handle different container types
+ switch c := container.(type) {
+ case string:
+ return strings.Contains(c, itemStr), nil
+ case []interface{}:
+ for _, v := range c {
+ if ctx.equals(v, item) {
+ return true, nil
+ }
+ }
+ case map[string]interface{}:
+ for k := range c {
+ if k == itemStr {
+ return true, nil
+ }
+ }
+ default:
+ // Try reflection for other types
+ rv := reflect.ValueOf(container)
+ switch rv.Kind() {
+ case reflect.String:
+ return strings.Contains(rv.String(), itemStr), nil
+ case reflect.Array, reflect.Slice:
+ for i := 0; i < rv.Len(); i++ {
+ if ctx.equals(rv.Index(i).Interface(), item) {
+ return true, nil
+ }
+ }
+ case reflect.Map:
+ for _, key := range rv.MapKeys() {
+ if ctx.equals(key.Interface(), item) {
+ return true, nil
+ }
+ }
+ }
+ }
+
+ return false, nil
+}
+
+// equals checks if two values are equal
+func (ctx *RenderContext) equals(a, b interface{}) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+
+ // Try numeric comparison
+ if aNum, aok := ctx.toNumber(a); aok {
+ if bNum, bok := ctx.toNumber(b); bok {
+ return aNum == bNum
+ }
+ }
+
+ // Try string comparison
+ return ctx.ToString(a) == ctx.ToString(b)
+}
+
+// toNumber converts a value to a float64, returning ok=false if not possible
+func (ctx *RenderContext) toNumber(val interface{}) (float64, bool) {
+ if val == nil {
+ return 0, false
+ }
+
+ switch v := val.(type) {
+ case int:
+ return float64(v), true
+ case int8:
+ return float64(v), true
+ case int16:
+ return float64(v), true
+ case int32:
+ return float64(v), true
+ case int64:
+ return float64(v), true
+ case uint:
+ return float64(v), true
+ case uint8:
+ return float64(v), true
+ case uint16:
+ return float64(v), true
+ case uint32:
+ return float64(v), true
+ case uint64:
+ return float64(v), true
+ case float32:
+ return float64(v), true
+ case float64:
+ return v, true
+ case string:
+ // Try to parse as float64
+ if f, err := strconv.ParseFloat(v, 64); err == nil {
+ return f, true
+ }
+ return 0, false
+ case bool:
+ if v {
+ return 1, true
+ }
+ return 0, true
+ }
+
+ // Try reflection for custom types
+ rv := reflect.ValueOf(val)
+ switch rv.Kind() {
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ return float64(rv.Int()), true
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ return float64(rv.Uint()), true
+ case reflect.Float32, reflect.Float64:
+ return rv.Float(), true
+ }
+
+ return 0, false
+}
+
+// toBool converts a value to a boolean
+func (ctx *RenderContext) toBool(val interface{}) bool {
+ if val == nil {
+ return false
+ }
+
+ switch v := val.(type) {
+ case bool:
+ return v
+ case int, int8, int16, int32, int64:
+ return v != 0
+ case uint, uint8, uint16, uint32, uint64:
+ return v != 0
+ case float32, float64:
+ return v != 0
+ case string:
+ return v != ""
+ case []interface{}:
+ return len(v) > 0
+ case map[string]interface{}:
+ return len(v) > 0
+ }
+
+ // Try reflection for other types
+ rv := reflect.ValueOf(val)
+ switch rv.Kind() {
+ case reflect.Bool:
+ return rv.Bool()
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ return rv.Int() != 0
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ return rv.Uint() != 0
+ case reflect.Float32, reflect.Float64:
+ return rv.Float() != 0
+ case reflect.String:
+ return rv.String() != ""
+ case reflect.Array, reflect.Slice, reflect.Map:
+ return rv.Len() > 0
+ }
+
+ // Default to true for other non-nil values
+ return true
+}
+
+// ToString converts a value to a string
+func (ctx *RenderContext) ToString(val interface{}) string {
+ if val == nil {
+ return ""
+ }
+
+ switch v := val.(type) {
+ case string:
+ return v
+ case int:
+ return strconv.Itoa(v)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ case uint:
+ return strconv.FormatUint(uint64(v), 10)
+ case uint64:
+ return strconv.FormatUint(v, 10)
+ case float32:
+ return strconv.FormatFloat(float64(v), 'f', -1, 32)
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ case bool:
+ return strconv.FormatBool(v)
+ case []byte:
+ return string(v)
+ case fmt.Stringer:
+ return v.String()
+ }
+
+ return fmt.Sprintf("%v", val)
+}
\ No newline at end of file
diff --git a/render_filter.go b/render_filter.go
index da428c9..0069e06 100644
--- a/render_filter.go
+++ b/render_filter.go
@@ -1,6 +1,8 @@
package twig
-import "fmt"
+import (
+ "fmt"
+)
// ApplyFilter applies a filter to a value
func (ctx *RenderContext) ApplyFilter(name string, value interface{}, args ...interface{}) (interface{}, error) {
@@ -14,23 +16,81 @@ func (ctx *RenderContext) ApplyFilter(name string, value interface{}, args ...in
return nil, fmt.Errorf("filter '%s' not found", name)
}
-// Override for the original FilterNode evaluation in render.go
-func (ctx *RenderContext) evaluateFilterNode(n *FilterNode) (interface{}, error) {
- // Evaluate the base value
- value, err := ctx.EvaluateExpression(n.node)
- if err != nil {
- return nil, err
- }
+// FilterChainItem represents a single filter in a chain
+type FilterChainItem struct {
+ name string
+ args []interface{}
+}
- // Evaluate filter arguments
- args := make([]interface{}, len(n.args))
- for i, arg := range n.args {
- val, err := ctx.EvaluateExpression(arg)
+// DetectFilterChain analyzes a filter node and extracts all filters in the chain
+// Returns the base node and a slice of all filters to be applied
+func (ctx *RenderContext) DetectFilterChain(node Node) (Node, []FilterChainItem, error) {
+ var chain []FilterChainItem
+ currentNode := node
+
+ // Traverse down the filter chain, collecting filters as we go
+ for {
+ // Check if the current node is a filter node
+ filterNode, isFilter := currentNode.(*FilterNode)
+ if !isFilter {
+ // We've reached the base value node
+ break
+ }
+
+ // Evaluate filter arguments
+ args := make([]interface{}, len(filterNode.args))
+ for i, arg := range filterNode.args {
+ val, err := ctx.EvaluateExpression(arg)
+ if err != nil {
+ return nil, nil, err
+ }
+ args[i] = val
+ }
+
+ // Add this filter to the chain (prepend to maintain order)
+ chain = append([]FilterChainItem{{
+ name: filterNode.filter,
+ args: args,
+ }}, chain...)
+
+ // Continue with the next node in the chain
+ currentNode = filterNode.node
+ }
+
+ return currentNode, chain, nil
+}
+
+// ApplyFilterChain applies a chain of filters to a value
+func (ctx *RenderContext) ApplyFilterChain(baseValue interface{}, chain []FilterChainItem) (interface{}, error) {
+ // Start with the base value
+ result := baseValue
+ var err error
+
+ // Apply each filter in the chain
+ for _, filter := range chain {
+ result, err = ctx.ApplyFilter(filter.name, result, filter.args...)
if err != nil {
return nil, err
}
- args[i] = val
}
+
+ return result, nil
+}
- return ctx.ApplyFilter(n.filter, value, args...)
+// Override for the original FilterNode evaluation in render.go
+func (ctx *RenderContext) evaluateFilterNode(n *FilterNode) (interface{}, error) {
+ // Detect the complete filter chain
+ baseNode, filterChain, err := ctx.DetectFilterChain(n)
+ if err != nil {
+ return nil, err
+ }
+
+ // Evaluate the base value
+ value, err := ctx.EvaluateExpression(baseNode)
+ if err != nil {
+ return nil, err
+ }
+
+ // Apply the entire filter chain in a single operation
+ return ctx.ApplyFilterChain(value, filterChain)
}
\ No newline at end of file
diff --git a/twig.go b/twig.go
index b6cb238..ccfdd21 100644
--- a/twig.go
+++ b/twig.go
@@ -6,6 +6,7 @@ import (
"bytes"
"io"
"sync"
+ "time"
)
// Engine represents the Twig template engine
@@ -23,11 +24,13 @@ type Engine struct {
// Template represents a parsed and compiled Twig template
type Template struct {
- name string
- source string
- nodes Node
- env *Environment
- engine *Engine // Reference back to the engine for loading parent templates
+ name string
+ source string
+ nodes Node
+ env *Environment
+ engine *Engine // Reference back to the engine for loading parent templates
+ loader Loader // The loader that loaded this template
+ lastModified int64 // Last modified timestamp for this template
}
// Environment holds configuration and context for template rendering
@@ -53,11 +56,14 @@ func New() *Engine {
tests: make(map[string]TestFunc),
operators: make(map[string]OperatorFunc),
autoescape: true,
+ cache: true, // Enable caching by default
+ debug: false, // Disable debug mode by default
}
engine := &Engine{
templates: make(map[string]*Template),
environment: env,
+ autoReload: false, // Disable auto-reload by default
}
// Register the core extension by default
@@ -81,6 +87,24 @@ func (e *Engine) SetStrictVars(strictVars bool) {
e.strictVars = strictVars
}
+// SetDebug enables or disables debug mode
+func (e *Engine) SetDebug(enabled bool) {
+ e.environment.debug = enabled
+}
+
+// SetCache enables or disables template caching
+func (e *Engine) SetCache(enabled bool) {
+ e.environment.cache = enabled
+}
+
+// SetDevelopmentMode enables settings appropriate for development
+// This sets debug mode on, enables auto-reload, and disables caching
+func (e *Engine) SetDevelopmentMode(enabled bool) {
+ e.environment.debug = enabled
+ e.autoReload = enabled
+ e.environment.cache = !enabled
+}
+
// Render renders a template with the given context
func (e *Engine) Render(name string, context map[string]interface{}) (string, error) {
template, err := e.Load(name)
@@ -103,19 +127,51 @@ func (e *Engine) RenderTo(w io.Writer, name string, context map[string]interface
// Load loads a template by name
func (e *Engine) Load(name string) (*Template, error) {
- e.mu.RLock()
- if tmpl, ok := e.templates[name]; ok {
- e.mu.RUnlock()
- return tmpl, nil
+ // Only check the cache if caching is enabled
+ if e.environment.cache {
+ e.mu.RLock()
+ if tmpl, ok := e.templates[name]; ok {
+ e.mu.RUnlock()
+
+ // If auto-reload is disabled, return the cached template
+ if !e.autoReload {
+ return tmpl, nil
+ }
+
+ // If auto-reload is enabled, check if the template has been modified
+ if tmpl.loader != nil {
+ // Check if the loader supports timestamp checking
+ if tsLoader, ok := tmpl.loader.(TimestampAwareLoader); ok {
+ // Get the current modification time
+ currentModTime, err := tsLoader.GetModifiedTime(name)
+ if err == nil && currentModTime <= tmpl.lastModified {
+ // Template hasn't been modified, use the cached version
+ return tmpl, nil
+ }
+ }
+ }
+ } else {
+ e.mu.RUnlock()
+ }
}
- e.mu.RUnlock()
+ // Template not in cache or cache disabled or needs reloading
+ var lastModified int64
+ var sourceLoader Loader
+
for _, loader := range e.loaders {
source, err := loader.Load(name)
if err != nil {
continue
}
-
+
+ // If this loader supports modification times, get the time
+ if tsLoader, ok := loader.(TimestampAwareLoader); ok {
+ lastModified, _ = tsLoader.GetModifiedTime(name)
+ }
+
+ sourceLoader = loader
+
parser := &Parser{}
nodes, err := parser.Parse(source)
if err != nil {
@@ -123,16 +179,21 @@ func (e *Engine) Load(name string) (*Template, error) {
}
template := &Template{
- name: name,
- source: source,
- nodes: nodes,
- env: e.environment,
- engine: e, // Add reference to the engine
+ name: name,
+ source: source,
+ nodes: nodes,
+ env: e.environment,
+ engine: e, // Add reference to the engine
+ loader: sourceLoader,
+ lastModified: lastModified,
}
- e.mu.Lock()
- e.templates[name] = template
- e.mu.Unlock()
+ // Only cache if caching is enabled
+ if e.environment.cache {
+ e.mu.Lock()
+ e.templates[name] = template
+ e.mu.Unlock()
+ }
return template, nil
}
@@ -148,17 +209,26 @@ func (e *Engine) RegisterString(name string, source string) error {
return err
}
+ // String templates always use the current time as modification time
+ // and null loader since they're not loaded from a file
+ now := time.Now().Unix()
+
template := &Template{
- name: name,
- source: source,
- nodes: nodes,
- env: e.environment,
- engine: e,
+ name: name,
+ source: source,
+ nodes: nodes,
+ env: e.environment,
+ engine: e,
+ lastModified: now,
+ loader: nil, // String templates don't have a loader
}
- e.mu.Lock()
- e.templates[name] = template
- e.mu.Unlock()
+ // Only cache if caching is enabled
+ if e.environment.cache {
+ e.mu.Lock()
+ e.templates[name] = template
+ e.mu.Unlock()
+ }
return nil
}
@@ -168,11 +238,52 @@ func (e *Engine) GetEnvironment() *Environment {
return e.environment
}
+// IsDebugEnabled returns true if debug mode is enabled
+func (e *Engine) IsDebugEnabled() bool {
+ return e.environment.debug
+}
+
+// IsCacheEnabled returns true if caching is enabled
+func (e *Engine) IsCacheEnabled() bool {
+ return e.environment.cache
+}
+
+// IsAutoReloadEnabled returns true if auto-reload is enabled
+func (e *Engine) IsAutoReloadEnabled() bool {
+ return e.autoReload
+}
+
+// GetCachedTemplateCount returns the number of templates in the cache
+func (e *Engine) GetCachedTemplateCount() int {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ return len(e.templates)
+}
+
+// GetCachedTemplateNames returns a list of template names in the cache
+func (e *Engine) GetCachedTemplateNames() []string {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ names := make([]string, 0, len(e.templates))
+ for name := range e.templates {
+ names = append(names, name)
+ }
+ return names
+}
+
// RegisterTemplate directly registers a pre-built template
func (e *Engine) RegisterTemplate(name string, template *Template) {
- e.mu.Lock()
- e.templates[name] = template
- e.mu.Unlock()
+ // Set the lastModified timestamp if it's not already set
+ if template.lastModified == 0 {
+ template.lastModified = time.Now().Unix()
+ }
+
+ // Only cache if caching is enabled
+ if e.environment.cache {
+ e.mu.Lock()
+ e.templates[name] = template
+ e.mu.Unlock()
+ }
}
// AddFilter registers a custom filter function
@@ -272,11 +383,13 @@ func (e *Engine) RegisterExtension(name string, config func(*CustomExtension)) {
// NewTemplate creates a new template with the given parameters
func (e *Engine) NewTemplate(name string, source string, nodes Node) *Template {
return &Template{
- name: name,
- source: source,
- nodes: nodes,
- env: e.environment,
- engine: e,
+ name: name,
+ source: source,
+ nodes: nodes,
+ env: e.environment,
+ engine: e,
+ lastModified: time.Now().Unix(),
+ loader: nil,
}
}
@@ -294,10 +407,12 @@ func (e *Engine) ParseTemplate(source string) (*Template, error) {
}
template := &Template{
- source: source,
- nodes: nodes,
- env: e.environment,
- engine: e,
+ source: source,
+ nodes: nodes,
+ env: e.environment,
+ engine: e,
+ lastModified: time.Now().Unix(),
+ loader: nil,
}
return template, nil
diff --git a/twig_test.go b/twig_test.go
index 1e15bdd..283c496 100644
--- a/twig_test.go
+++ b/twig_test.go
@@ -2,7 +2,10 @@ package twig
import (
"bytes"
+ "os"
+ "path/filepath"
"testing"
+ "time"
)
func TestBasicTemplate(t *testing.T) {
@@ -921,4 +924,282 @@ func TestOperators(t *testing.T) {
}
})
}
+}
+
+func TestFilterChainOptimization(t *testing.T) {
+ // Create a Twig engine
+ engine := New()
+
+ // Create a template with a long filter chain
+ source := "{{ 'hello world'|upper|trim|slice(0, 5)|replace('H', 'J')|upper }}"
+
+ // Parse the template
+ parser := &Parser{}
+ node, err := parser.Parse(source)
+ if err != nil {
+ t.Fatalf("Error parsing template: %v", err)
+ }
+
+ // Create a template
+ template := &Template{
+ source: source,
+ nodes: node,
+ env: engine.environment,
+ engine: engine,
+ }
+
+ // Render the template
+ result, err := template.Render(nil)
+ if err != nil {
+ t.Fatalf("Error rendering template: %v", err)
+ }
+
+ // Verify the expected output (should get the same result with optimized chain)
+ expected := "JELLO"
+ if result != expected {
+ t.Errorf("Filter chain optimization returned incorrect result: expected %q, got %q", expected, result)
+ }
+}
+
+func BenchmarkFilterChain(b *testing.B) {
+ // Create a Twig engine
+ engine := New()
+
+ // Create a template with a long filter chain
+ source := "{{ 'hello world'|upper|trim|slice(0, 5)|replace('H', 'J') }}"
+
+ // Parse the template
+ parser := &Parser{}
+ node, err := parser.Parse(source)
+ if err != nil {
+ b.Fatalf("Error parsing template: %v", err)
+ }
+
+ // Create a template
+ template := &Template{
+ source: source,
+ nodes: node,
+ env: engine.environment,
+ engine: engine,
+ }
+
+ // Bench the rendering
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := template.Render(nil)
+ if err != nil {
+ b.Fatalf("Error during benchmark: %v", err)
+ }
+ }
+}
+
+func TestTemplateModificationTime(t *testing.T) {
+ // Create a temporary directory for template files
+ tempDir := t.TempDir()
+
+ // Create a test template file
+ templatePath := filepath.Join(tempDir, "test.twig")
+ initialContent := "Hello,{{ name }}!"
+ err := os.WriteFile(templatePath, []byte(initialContent), 0644)
+ if err != nil {
+ t.Fatalf("Failed to create test template: %v", err)
+ }
+
+ // Create a Twig engine
+ engine := New()
+
+ // Register a file system loader pointing to our temp directory
+ loader := NewFileSystemLoader([]string{tempDir})
+ engine.RegisterLoader(loader)
+
+ // Enable auto-reload
+ engine.SetAutoReload(true)
+
+ // First load of the template
+ template1, err := engine.Load("test")
+ if err != nil {
+ t.Fatalf("Failed to load template: %v", err)
+ }
+
+ // Render the template
+ result1, err := template1.Render(map[string]interface{}{"name": "World"})
+ if err != nil {
+ t.Fatalf("Failed to render template: %v", err)
+ }
+ if result1 != "Hello,World!" {
+ t.Errorf("Expected 'Hello,World!', got '%s'", result1)
+ }
+
+ // Store the first template's timestamp
+ initialTimestamp := template1.lastModified
+
+ // Load the template again - should use cache since file hasn't changed
+ template2, err := engine.Load("test")
+ if err != nil {
+ t.Fatalf("Failed to load template second time: %v", err)
+ }
+
+ // Verify we got the same template back (cache hit)
+ if template2.lastModified != initialTimestamp {
+ t.Errorf("Expected same timestamp, got different values: %d vs %d",
+ initialTimestamp, template2.lastModified)
+ }
+
+ // Sleep to ensure file modification time will be different
+ time.Sleep(1 * time.Second)
+
+ // Modify the template file
+ modifiedContent := "Greetings,{{ name }}!"
+ err = os.WriteFile(templatePath, []byte(modifiedContent), 0644)
+ if err != nil {
+ t.Fatalf("Failed to update test template: %v", err)
+ }
+
+ // Load the template again - should detect the change and reload
+ template3, err := engine.Load("test")
+ if err != nil {
+ t.Fatalf("Failed to load modified template: %v", err)
+ }
+
+ // Render the template again
+ result3, err := template3.Render(map[string]interface{}{"name": "World"})
+ if err != nil {
+ t.Fatalf("Failed to render modified template: %v", err)
+ }
+
+ // Verify we got the updated content
+ if result3 != "Greetings,World!" {
+ t.Errorf("Expected 'Greetings,World!', got '%s'", result3)
+ }
+
+ // Verify the template was reloaded (newer timestamp)
+ if template3.lastModified <= initialTimestamp {
+ t.Errorf("Expected newer timestamp, but got %d <= %d",
+ template3.lastModified, initialTimestamp)
+ }
+
+ // Disable auto-reload
+ engine.SetAutoReload(false)
+
+ // Sleep to ensure file modification time will be different
+ time.Sleep(1 * time.Second)
+
+ // Modify the template file again
+ finalContent := "Welcome,{{ name }}!"
+ err = os.WriteFile(templatePath, []byte(finalContent), 0644)
+ if err != nil {
+ t.Fatalf("Failed to update test template again: %v", err)
+ }
+
+ // Load the template again - should NOT detect the change because auto-reload is off
+ template4, err := engine.Load("test")
+ if err != nil {
+ t.Fatalf("Failed to load template with auto-reload off: %v", err)
+ }
+
+ // Render the template again
+ result4, err := template4.Render(map[string]interface{}{"name": "World"})
+ if err != nil {
+ t.Fatalf("Failed to render template with auto-reload off: %v", err)
+ }
+
+ // Verify we still have the previous content, not the updated one
+ if result4 != "Greetings,World!" {
+ t.Errorf("Expected 'Greetings,World!', got '%s'", result4)
+ }
+}
+
+func TestDevelopmentMode(t *testing.T) {
+ // Create a new engine
+ engine := New()
+
+ // Verify default settings
+ if !engine.environment.cache {
+ t.Errorf("Cache should be enabled by default")
+ }
+ if engine.environment.debug {
+ t.Errorf("Debug should be disabled by default")
+ }
+ if engine.autoReload {
+ t.Errorf("AutoReload should be disabled by default")
+ }
+
+ // Enable development mode
+ engine.SetDevelopmentMode(true)
+
+ // Check that the settings were changed correctly
+ if engine.environment.cache {
+ t.Errorf("Cache should be disabled in development mode")
+ }
+ if !engine.environment.debug {
+ t.Errorf("Debug should be enabled in development mode")
+ }
+ if !engine.autoReload {
+ t.Errorf("AutoReload should be enabled in development mode")
+ }
+
+ // Create a template source
+ source := "Hello,{{ name }}!"
+
+ // Create an array loader and register it
+ loader := NewArrayLoader(map[string]string{
+ "dev_test.twig": source,
+ })
+ engine.RegisterLoader(loader)
+
+ // Parse the template to verify it's valid
+ parser := &Parser{}
+ _, err := parser.Parse(source)
+ if err != nil {
+ t.Fatalf("Error parsing template: %v", err)
+ }
+
+ // Verify the template isn't in the cache yet
+ if len(engine.templates) > 0 {
+ t.Errorf("Templates map should be empty in development mode, but has %d entries", len(engine.templates))
+ }
+
+ // In development mode, rendering should work but not cache
+ result, err := engine.Render("dev_test.twig", map[string]interface{}{
+ "name": "World",
+ })
+ if err != nil {
+ t.Fatalf("Error rendering template in development mode: %v", err)
+ }
+ if result != "Hello,World!" {
+ t.Errorf("Expected 'Hello,World!', got '%s'", result)
+ }
+
+ // Disable development mode
+ engine.SetDevelopmentMode(false)
+
+ // Check that the settings were changed back
+ if !engine.environment.cache {
+ t.Errorf("Cache should be enabled when development mode is off")
+ }
+ if engine.environment.debug {
+ t.Errorf("Debug should be disabled when development mode is off")
+ }
+ if engine.autoReload {
+ t.Errorf("AutoReload should be disabled when development mode is off")
+ }
+
+ // In production mode, rendering should cache the template
+ result, err = engine.Render("dev_test.twig", map[string]interface{}{
+ "name": "World",
+ })
+ if err != nil {
+ t.Fatalf("Error rendering template in production mode: %v", err)
+ }
+ if result != "Hello,World!" {
+ t.Errorf("Expected 'Hello,World!', got '%s'", result)
+ }
+
+ // Template should now be in the cache
+ if len(engine.templates) != 1 {
+ t.Errorf("Templates map should have 1 entry, but has %d", len(engine.templates))
+ }
+ if _, ok := engine.templates["dev_test.twig"]; !ok {
+ t.Errorf("Template should be in the cache")
+ }
}
\ No newline at end of file