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 %} + + {% 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 %} + + {% 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 %} + + {% 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