go-twig/render.go
semihalev 2419e0e574 Fix relative path resolution for template loading
Fixed the issue with relative path resolution by:
1. Adding a directory field to Template struct to store template's path
2. Adding lastLoadedTemplate field to RenderContext to track template context
3. Updating Clone method to preserve lastLoadedTemplate reference
4. Setting lastLoadedTemplate in all render contexts to maintain path context

This ensures that when templates include/extend/import other templates
using relative paths (starting with ./ or ../), the paths are properly
resolved relative to the original template's location.
2025-03-12 12:51:51 +03:00

1855 lines
47 KiB
Go

// Revert to backup file and use the content with our changes
package twig
import (
"errors"
"fmt"
"io"
"math"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// RenderContext holds the state during template rendering
type RenderContext struct {
env *Environment
context map[string]interface{}
blocks map[string][]Node
parentBlocks map[string][]Node // Original block content from parent templates
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)
inParentCall bool // Flag to indicate if we're currently rendering a parent() call
sandboxed bool // Flag indicating if this context is sandboxed
lastLoadedTemplate *Template // The template that created this context (for resolving relative paths)
}
// contextMapPool is a pool for the maps used in RenderContext
var contextMapPool = sync.Pool{
New: func() interface{} {
return make(map[string]interface{}, 16) // Pre-allocate with reasonable size
},
}
// blocksMapPool is a pool for the blocks map used in RenderContext
var blocksMapPool = sync.Pool{
New: func() interface{} {
return make(map[string][]Node, 8) // Pre-allocate with reasonable size
},
}
// macrosMapPool is a pool for the macros map used in RenderContext
var macrosMapPool = sync.Pool{
New: func() interface{} {
return make(map[string]Node, 8) // Pre-allocate with reasonable size
},
}
// renderContextPool is a sync.Pool for RenderContext objects
var renderContextPool = sync.Pool{
New: func() interface{} {
return &RenderContext{
context: contextMapPool.Get().(map[string]interface{}),
blocks: blocksMapPool.Get().(map[string][]Node),
parentBlocks: blocksMapPool.Get().(map[string][]Node),
macros: macrosMapPool.Get().(map[string]Node),
}
},
}
// NewRenderContext gets a RenderContext from the pool and initializes it
func NewRenderContext(env *Environment, context map[string]interface{}, engine *Engine) *RenderContext {
ctx := renderContextPool.Get().(*RenderContext)
// Ensure all maps are initialized (should be from the pool)
if ctx.context == nil {
ctx.context = contextMapPool.Get().(map[string]interface{})
} else {
// Clear any existing data
for k := range ctx.context {
delete(ctx.context, k)
}
}
if ctx.blocks == nil {
ctx.blocks = blocksMapPool.Get().(map[string][]Node)
} else {
// Clear any existing data
for k := range ctx.blocks {
delete(ctx.blocks, k)
}
}
if ctx.parentBlocks == nil {
ctx.parentBlocks = blocksMapPool.Get().(map[string][]Node)
} else {
// Clear any existing data
for k := range ctx.parentBlocks {
delete(ctx.parentBlocks, k)
}
}
if ctx.macros == nil {
ctx.macros = macrosMapPool.Get().(map[string]Node)
} else {
// Clear any existing data
for k := range ctx.macros {
delete(ctx.macros, k)
}
}
// Set basic properties
ctx.env = env
ctx.engine = engine
ctx.extending = false
ctx.currentBlock = nil
ctx.parent = nil
ctx.inParentCall = false
ctx.sandboxed = false
// Copy the context values directly
if context != nil {
for k, v := range context {
ctx.context[k] = v
}
}
return ctx
}
// Release returns the RenderContext to the pool with proper cleanup
func (ctx *RenderContext) Release() {
// Clear references to large objects to prevent memory leaks
ctx.env = nil
ctx.engine = nil
ctx.currentBlock = nil
// Save the maps so we can return them to their respective pools
contextMap := ctx.context
blocksMap := ctx.blocks
parentBlocksMap := ctx.parentBlocks
macrosMap := ctx.macros
// Clear the maps from the context
ctx.context = nil
ctx.blocks = nil
ctx.parentBlocks = nil
ctx.macros = nil
// Don't release parent contexts - they'll be released separately
ctx.parent = nil
// Return to pool
renderContextPool.Put(ctx)
// Clear map contents and return them to their pools
if contextMap != nil {
for k := range contextMap {
delete(contextMap, k)
}
contextMapPool.Put(contextMap)
}
if blocksMap != nil {
for k := range blocksMap {
delete(blocksMap, k)
}
blocksMapPool.Put(blocksMap)
}
if parentBlocksMap != nil {
for k := range parentBlocksMap {
delete(parentBlocksMap, k)
}
blocksMapPool.Put(parentBlocksMap)
}
if macrosMap != nil {
for k := range macrosMap {
delete(macrosMap, k)
}
macrosMapPool.Put(macrosMap)
}
}
// 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 if this looks like an array literal - this is a hack to handle
// the case where the array literal is parsed as a variable name
if len(name) >= 2 && name[0] == '[' && strings.Contains(name, "]") {
// This looks like an array literal that was parsed as a variable name
// We'll parse it manually here
// Extract the content between [ and ]
content := name[1:strings.LastIndex(name, "]")]
// Split by commas
parts := strings.Split(content, ",")
// Create a result array
result := make([]interface{}, 0, len(parts))
// Process each element
for _, part := range parts {
// Trim whitespace and quotes
element := strings.TrimSpace(part)
// If it's a quoted string, remove the quotes
if len(element) >= 2 && (element[0] == '"' || element[0] == '\'') && element[0] == element[len(element)-1] {
element = element[1 : len(element)-1]
}
result = append(result, element)
}
return result, nil
}
// Fallback ternary expression parser for backward compatibility
// This handles cases where the parser didn't correctly handle ternary expressions
if strings.Contains(name, "?") && strings.Contains(name, ":") {
LogDebug("Parsing inline ternary expression: %s", name)
// Simple ternary expression handler
parts := strings.SplitN(name, "?", 2)
condition := strings.TrimSpace(parts[0])
branches := strings.SplitN(parts[1], ":", 2)
if len(branches) != 2 {
return nil, fmt.Errorf("malformed ternary expression: %s", name)
}
trueExpr := strings.TrimSpace(branches[0])
falseExpr := strings.TrimSpace(branches[1])
// Evaluate condition
var condValue bool
if condition == "true" {
condValue = true
} else if condition == "false" {
condValue = false
} else {
// Try to get variable value
condVar, _ := ctx.GetVariable(condition)
condValue = ctx.toBool(condVar)
}
// Evaluate the appropriate branch
if condValue {
return ctx.GetVariable(trueExpr)
} else {
return ctx.GetVariable(falseExpr)
}
}
// 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
}
// GetVariableOrNil gets a variable from the context, returning nil silently if not found
func (ctx *RenderContext) GetVariableOrNil(name string) interface{} {
value, _ := ctx.GetVariable(name)
return value
}
// SetVariable sets a variable in the context
func (ctx *RenderContext) SetVariable(name string, value interface{}) {
ctx.context[name] = value
}
// GetEnvironment returns the environment
func (ctx *RenderContext) GetEnvironment() *Environment {
return ctx.env
}
// GetEngine returns the engine
func (ctx *RenderContext) GetEngine() *Engine {
return ctx.engine
}
// SetParent sets the parent context
func (ctx *RenderContext) SetParent(parent *RenderContext) {
ctx.parent = parent
}
// EnableSandbox enables sandbox mode on this context
func (ctx *RenderContext) EnableSandbox() {
ctx.sandboxed = true
}
// IsSandboxed returns whether this context is sandboxed
func (ctx *RenderContext) IsSandboxed() bool {
return ctx.sandboxed
}
// Clone creates a new context as a child of the current context
func (ctx *RenderContext) Clone() *RenderContext {
// Get a new context from the pool with empty maps
newCtx := renderContextPool.Get().(*RenderContext)
// Initialize the context
newCtx.env = ctx.env
newCtx.engine = ctx.engine
newCtx.extending = false
newCtx.currentBlock = nil
newCtx.parent = ctx
newCtx.inParentCall = false
// Inherit sandbox state
newCtx.sandboxed = ctx.sandboxed
// Copy the lastLoadedTemplate reference (crucial for relative path resolution)
newCtx.lastLoadedTemplate = ctx.lastLoadedTemplate
// Ensure maps are initialized (they should be from the pool already)
if newCtx.context == nil {
newCtx.context = contextMapPool.Get().(map[string]interface{})
} else {
// Clear any existing data
for k := range newCtx.context {
delete(newCtx.context, k)
}
}
if newCtx.blocks == nil {
newCtx.blocks = blocksMapPool.Get().(map[string][]Node)
} else {
// Clear any existing data
for k := range newCtx.blocks {
delete(newCtx.blocks, k)
}
}
if newCtx.macros == nil {
newCtx.macros = macrosMapPool.Get().(map[string]Node)
} else {
// Clear any existing data
for k := range newCtx.macros {
delete(newCtx.macros, k)
}
}
if newCtx.parentBlocks == nil {
newCtx.parentBlocks = blocksMapPool.Get().(map[string][]Node)
} else {
// Clear any existing data
for k := range newCtx.parentBlocks {
delete(newCtx.parentBlocks, k)
}
}
// Copy blocks by reference (no need to deep copy)
for name, nodes := range ctx.blocks {
newCtx.blocks[name] = nodes
}
// Copy macros by reference (no need to deep copy)
for name, macro := range ctx.macros {
newCtx.macros[name] = macro
}
return newCtx
}
// GetMacro gets a macro from the context
func (ctx *RenderContext) GetMacro(name string) (interface{}, 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
}
// GetMacros returns the macros map
func (ctx *RenderContext) GetMacros() map[string]Node {
return ctx.macros
}
// InitMacros initializes the macros map if it's nil
func (ctx *RenderContext) InitMacros() {
if ctx.macros == nil {
ctx.macros = macrosMapPool.Get().(map[string]Node)
}
}
// SetMacro sets a macro in the context
func (ctx *RenderContext) SetMacro(name string, macro Node) {
if ctx.macros == nil {
ctx.macros = macrosMapPool.Get().(map[string]Node)
}
ctx.macros[name] = macro
}
// 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.CallMacro(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 {
// Special case for parent() function which needs access to the RenderContext
if name == "parent" {
return fn(args...)
}
// Regular function call
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.CallMacro(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
result := make([]interface{}, 0)
if step > 0 {
for i := start; i <= end; i += step {
result = append(result, int(i))
}
} else {
for i := start; i >= end; i += step {
result = append(result, int(i))
}
}
// Always return a non-nil slice for the for loop
if len(result) == 0 {
return []interface{}{}, nil
}
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) {
if node == nil {
return nil, nil
}
// Check sandbox security if enabled
if ctx.sandboxed && ctx.env.securityPolicy != nil {
switch n := node.(type) {
case *FunctionNode:
if !ctx.env.securityPolicy.IsFunctionAllowed(n.name) {
return nil, NewFunctionViolation(n.name)
}
case *FilterNode:
if !ctx.env.securityPolicy.IsFilterAllowed(n.filter) {
return nil, NewFilterViolation(n.filter)
}
}
}
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 *GetItemNode:
// Evaluate the container (array, slice, map)
container, err := ctx.EvaluateExpression(n.node)
if err != nil {
return nil, err
}
// Evaluate the item index/key
index, err := ctx.EvaluateExpression(n.item)
if err != nil {
return nil, err
}
return ctx.getItem(container, index)
case *BinaryNode:
// First, evaluate the left side of the expression
left, err := ctx.EvaluateExpression(n.left)
if err != nil {
return nil, err
}
// Implement short-circuit evaluation for logical operators
if n.operator == "and" || n.operator == "&&" {
// For "and" operator, if left side is false, return false without evaluating right side
if !ctx.toBool(left) {
return false, nil
}
} else if n.operator == "or" || n.operator == "||" {
// For "or" operator, if left side is true, return true without evaluating right side
if ctx.toBool(left) {
return true, nil
}
}
// For other operators or if short-circuit condition not met, evaluate right side
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 {
// Log error if debug is enabled
if IsDebugEnabled() {
LogError(err, "Error evaluating 'if' condition")
}
return nil, err
}
// Log result if debug is enabled
conditionResult := ctx.toBool(condResult)
if IsDebugEnabled() {
LogDebug("Ternary condition result: %v (type: %T, raw value: %v)", conditionResult, condResult, condResult)
LogDebug("Branches: true=%T, false=%T", n.trueExpr, n.falseExpr)
}
// 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:
// We need to avoid pooling for arrays that might be directly used by filters like merge
// as those filters return the slice directly to the user
items := make([]interface{}, 0, len(n.items))
for i := 0; i < len(n.items); i++ {
val, err := ctx.EvaluateExpression(n.items[i])
if err != nil {
return nil, err
}
items = append(items, val)
}
// Always return a non-nil slice, even if empty
if len(items) == 0 {
return []interface{}{}, nil
}
return items, nil
case *HashNode:
// Evaluate each key-value pair in the hash using a new map
// We can't use pooling with defer here because the map is returned directly
result := make(map[string]interface{}, len(n.items))
for k, v := range n.items {
// Evaluate the key
keyVal, err := ctx.EvaluateExpression(k)
if err != nil {
return nil, err
}
// Convert key to string
key := ctx.ToString(keyVal)
// Evaluate the value
val, err := ctx.EvaluateExpression(v)
if err != nil {
return nil, err
}
// Store in the map
result[key] = val
}
return result, nil
case *FunctionNode:
// Check if this is a module.function() call (moduleExpr will be non-nil)
if n.moduleExpr != nil {
if IsDebugEnabled() && debugger.level >= DebugVerbose {
LogVerbose("Handling module.function() call with module expression")
}
// Evaluate the module expression first
moduleObj, err := ctx.EvaluateExpression(n.moduleExpr)
if err != nil {
return nil, err
}
// Evaluate all arguments - need direct allocation
args := make([]interface{}, len(n.args))
for i := 0; i < len(n.args); i++ {
val, err := ctx.EvaluateExpression(n.args[i])
if err != nil {
return nil, err
}
args[i] = val
}
// Check if moduleObj is a map that contains macros
if moduleMap, ok := moduleObj.(map[string]interface{}); ok {
if macroObj, ok := moduleMap[n.name]; ok {
if IsDebugEnabled() && debugger.level >= DebugVerbose {
LogVerbose("Found macro '%s' in module map", n.name)
}
// If the macro is a MacroNode, return a callable to render it
if macroNode, ok := macroObj.(*MacroNode); ok {
// Return a callable that can be rendered later
return func(w io.Writer) error {
return macroNode.CallMacro(w, ctx, args...)
}, nil
}
}
}
// Fallback - try calling it like a regular function
if IsDebugEnabled() && debugger.level >= DebugVerbose {
LogVerbose("Fallback - calling '%s' as a regular function", n.name)
}
result, err := ctx.CallFunction(n.name, args)
if err != nil {
return nil, err
}
return result, nil
}
// Check if it's a macro call
if macro, ok := ctx.GetMacro(n.name); ok {
// Evaluate arguments - need direct allocation for macro calls
args := make([]interface{}, len(n.args))
// Evaluate arguments
for i := 0; i < len(n.args); i++ {
val, err := ctx.EvaluateExpression(n.args[i])
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.CallMacro(w, ctx, args...)
}, nil
}
// Otherwise, it's a regular function call
// Evaluate arguments - need direct allocation for function calls
args := make([]interface{}, len(n.args))
// Evaluate arguments
for i := 0; i < len(n.args); i++ {
val, err := ctx.EvaluateExpression(n.args[i])
if err != nil {
return nil, err
}
args[i] = val
}
result, err := ctx.CallFunction(n.name, args)
if err != nil {
return nil, err
}
// Make sure function results that should be iterable actually are
if result == nil && (n.name == "range" || n.name == "length") {
return []interface{}{}, nil
}
return result, nil
case *FilterNode:
// Use the optimized filter chain implementation from render_filter.go
result, err := ctx.evaluateFilterNode(n)
if err != nil {
return nil, err
}
// Ensure filter results are never nil if they're expected to be iterable
if result == nil {
return "", nil
}
return result, nil
case *TestNode:
// Handle special "not defined" test (from parseBinaryExpression)
if n.test == "not defined" {
// Check if it's a variable reference
if varNode, ok := n.node.(*VariableNode); ok {
// Check directly in context
if ctx.context != nil {
_, exists := ctx.context[varNode.name]
if exists {
// If it exists, "not defined" is false
return false, nil
}
}
// Try full variable lookup
val, err := ctx.GetVariable(varNode.name)
// Return true if not defined (err != nil or val is nil)
return err != nil || val == nil, nil
}
// For non-variable nodes, assume defined
return false, nil
}
// Special handling for "is defined" test with attribute access
if n.test == "defined" {
// Check if this is a GetAttrNode
if getAttrNode, ok := n.node.(*GetAttrNode); ok {
// Evaluate the object
obj, err := ctx.EvaluateExpression(getAttrNode.node)
if err != nil {
return false, nil // If can't evaluate the object, it's not defined
}
// If obj is nil, attribute not defined
if obj == nil {
return false, nil
}
// Evaluate the attribute name
attrNameNode, err := ctx.EvaluateExpression(getAttrNode.attribute)
if err != nil {
return false, nil
}
attrName, ok := attrNameNode.(string)
if !ok {
return false, nil
}
// For maps, directly check if the key exists
if objMap, ok := obj.(map[string]interface{}); ok {
_, exists := objMap[attrName]
return exists, nil
}
// For other types, try to get the attribute but catch the error
_, err = ctx.getAttribute(obj, attrName)
return err == nil, nil
}
// Check for simple variable references
if varNode, ok := n.node.(*VariableNode); ok {
// Check directly in context
if ctx.context != nil {
_, exists := ctx.context[varNode.name]
if exists {
return true, nil
}
}
// Try full variable lookup
val, err := ctx.GetVariable(varNode.name)
return err == nil && val != nil, nil
}
}
// Standard test evaluation for all other cases
// Evaluate the tested value
value, err := ctx.EvaluateExpression(n.node)
if err != nil {
return nil, err
}
// Evaluate test arguments - need direct allocation
args := make([]interface{}, len(n.args))
// Evaluate arguments
for i := 0; i < len(n.args); i++ {
val, err := ctx.EvaluateExpression(n.args[i])
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", "!":
// Ensuring that the boolean conversion is correct before negation
result := ctx.toBool(operand)
return !result, 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)
}
}
// attributeCacheKey is used as a key for the attribute cache
type attributeCacheKey struct {
typ reflect.Type
attr string
}
// attributeCacheEntry represents a cached attribute lookup result
type attributeCacheEntry struct {
fieldIndex int // Index of the field (-1 if not a field)
isMethod bool // Whether this is a method
methodIndex int // Index of the method (-1 if not a method)
ptrMethod bool // Whether the method is on the pointer type
lastAccess time.Time // When this entry was last accessed
accessCount int // How many times this entry has been accessed
}
// attributeCache caches attribute lookups by type and attribute name
// Uses a simplified LRU strategy for eviction - when cache fills up,
// we remove 10% of the least recently used entries to make room
var attributeCache = struct {
sync.RWMutex
m map[attributeCacheKey]attributeCacheEntry
maxSize int // Maximum number of entries to cache
currSize int // Current number of entries
evictionPct float64 // Percentage of cache to evict when full (0.0-1.0)
}{
m: make(map[attributeCacheKey]attributeCacheEntry),
maxSize: 1000, // Limit cache to 1000 entries to prevent unbounded growth
evictionPct: 0.1, // Evict 10% of entries when cache is full
}
// evictLRUEntries removes the least recently used entries from the cache
// This function assumes that the caller holds the attributeCache lock
func evictLRUEntries() {
// Calculate how many entries to evict
numToEvict := int(float64(attributeCache.maxSize) * attributeCache.evictionPct)
if numToEvict < 1 {
numToEvict = 1 // Always evict at least one entry
}
// Create a slice of entries to sort by last access time
type cacheItem struct {
key attributeCacheKey
entry attributeCacheEntry
}
entries := make([]cacheItem, 0, attributeCache.currSize)
for k, v := range attributeCache.m {
entries = append(entries, cacheItem{k, v})
}
// Sort entries by last access time (oldest first)
sort.Slice(entries, func(i, j int) bool {
// If access counts differ by a significant amount, prefer keeping frequently accessed items
if entries[i].entry.accessCount < entries[j].entry.accessCount/2 {
return true
}
// Otherwise, use recency as the deciding factor
return entries[i].entry.lastAccess.Before(entries[j].entry.lastAccess)
})
// Remove the oldest entries
for i := 0; i < numToEvict && i < len(entries); i++ {
delete(attributeCache.m, entries[i].key)
attributeCache.currSize--
}
}
// getItem gets an item from a container (array, slice, map) by index or key
func (ctx *RenderContext) getItem(container, index interface{}) (interface{}, error) {
if container == nil {
return nil, nil
}
// Convert numeric indices to int for consistent handling
idx, _ := ctx.toNumber(index)
intIndex := int(idx)
// Handle different container types
switch c := container.(type) {
case []interface{}:
// Check bounds
if intIndex < 0 || intIndex >= len(c) {
return nil, fmt.Errorf("array index out of bounds: %d", intIndex)
}
return c[intIndex], nil
case map[string]interface{}:
// Try string key
if strKey, ok := index.(string); ok {
if value, exists := c[strKey]; exists {
return value, nil
}
}
// Try numeric key as string
strKey := ctx.ToString(index)
if value, exists := c[strKey]; exists {
return value, nil
}
return nil, nil // Nil for missing keys
default:
// Use reflection for other types
v := reflect.ValueOf(container)
switch v.Kind() {
case reflect.Slice, reflect.Array:
// Check bounds
if intIndex < 0 || intIndex >= v.Len() {
return nil, fmt.Errorf("array index out of bounds: %d", intIndex)
}
return v.Index(intIndex).Interface(), nil
case reflect.Map:
// Try to find the key
var mapKey reflect.Value
// Convert the index to the map's key type if possible
keyType := v.Type().Key()
indexValue := reflect.ValueOf(index)
if indexValue.Type().ConvertibleTo(keyType) {
mapKey = indexValue.Convert(keyType)
} else {
// Try string conversion for the key
strKey := ctx.ToString(index)
if reflect.TypeOf(strKey).ConvertibleTo(keyType) {
mapKey = reflect.ValueOf(strKey).Convert(keyType)
} else {
return nil, nil // Key type mismatch
}
}
mapValue := v.MapIndex(mapKey)
if mapValue.IsValid() {
return mapValue.Interface(), nil
}
}
}
return nil, nil // Default nil for non-indexable types
}
// getAttribute gets an attribute from an object
func (ctx *RenderContext) getAttribute(obj interface{}, attr string) (interface{}, error) {
if obj == nil {
// Instead of returning an error for nil objects, return nil value
return nil, nil
}
// Fast path for maps
if objMap, ok := obj.(map[string]interface{}); ok {
if value, exists := objMap[attr]; exists {
return value, nil
}
// For non-existent keys in maps, return nil instead of an error
return nil, nil
}
// Get the reflect.Value and type for the object
objValue := reflect.ValueOf(obj)
origType := objValue.Type()
// Handle pointer indirection
isPtr := origType.Kind() == reflect.Ptr
if isPtr {
objValue = objValue.Elem()
}
// Only use caching for struct types
if objValue.Kind() != reflect.Struct {
// Instead of returning an error for non-struct types, return nil
return nil, nil
}
objType := objValue.Type()
// Create a cache key
key := attributeCacheKey{
typ: objType,
attr: attr,
}
// Get a read lock to check the cache first
attributeCache.RLock()
entry, found := attributeCache.m[key]
if found {
// Found in cache, update access stats later with a write lock
attributeCache.RUnlock()
// Update the entry's access statistics with a write lock
attributeCache.Lock()
// Need to check again after acquiring write lock
if cachedEntry, stillExists := attributeCache.m[key]; stillExists {
// Update access time and count
cachedEntry.lastAccess = time.Now()
cachedEntry.accessCount++
attributeCache.m[key] = cachedEntry
entry = cachedEntry
}
attributeCache.Unlock()
} else {
// Not found in cache - release read lock and get write lock for update
attributeCache.RUnlock()
attributeCache.Lock()
// Double-check if another goroutine added it while we were waiting
entry, found = attributeCache.m[key]
if !found {
// Still not found, need to populate the cache
// Check if cache has reached maximum size
if attributeCache.currSize >= attributeCache.maxSize {
// Cache is full, use our LRU eviction strategy
evictLRUEntries()
}
// Create a new entry with current timestamp
entry = attributeCacheEntry{
fieldIndex: -1,
methodIndex: -1,
lastAccess: time.Now(),
accessCount: 1,
}
// Look for a field
field, found := objType.FieldByName(attr)
if found {
entry.fieldIndex = field.Index[0] // Assuming single-level field access
}
// Look for a method on the value
method, found := objType.MethodByName(attr)
if found && method.Type.NumIn() == 1 { // The receiver is the first argument
entry.isMethod = true
entry.methodIndex = method.Index
} else {
// Look for a method on the pointer to the value
ptrType := reflect.PtrTo(objType)
method, found := ptrType.MethodByName(attr)
if found && method.Type.NumIn() == 1 {
entry.isMethod = true
entry.ptrMethod = true
entry.methodIndex = method.Index
}
}
// Store in cache
attributeCache.m[key] = entry
attributeCache.currSize++
}
attributeCache.Unlock()
}
// Use the cached lookup information to get the attribute
// Try field access first
if entry.fieldIndex >= 0 {
field := objValue.Field(entry.fieldIndex)
if field.IsValid() && field.CanInterface() {
return field.Interface(), nil
}
}
// Try method access
if entry.isMethod && entry.methodIndex >= 0 {
var method reflect.Value
if entry.ptrMethod {
// Need a pointer to the struct
if isPtr {
// Object is already a pointer, use the original value
method = reflect.ValueOf(obj).Method(entry.methodIndex)
} else {
// Create a new pointer to the struct
ptrValue := reflect.New(objType)
ptrValue.Elem().Set(objValue)
method = ptrValue.Method(entry.methodIndex)
}
} else {
// Method is directly on the struct type
method = objValue.Method(entry.methodIndex)
}
if method.IsValid() {
results := method.Call(nil)
if len(results) > 0 {
return results[0].Interface(), nil
}
return nil, nil
}
}
// Instead of returning an error for attributes not found, just return nil
return nil, nil
}
// evaluateBinaryOp evaluates a binary operation
func (ctx *RenderContext) evaluateBinaryOp(operator string, left, right interface{}) (interface{}, error) {
// Check for the special case: 'not defined' test
if operator == "not" && (right == "defined" || right == string("defined")) {
// Special case for variable node check
if varNode, ok := left.(*VariableNode); ok {
// Get the variable name from the node
varName := varNode.name
// Check if the variable exists in the context
_, err := ctx.GetVariable(varName)
// Variable not defined = return true, otherwise false
return err != nil, nil
}
// Default to false for other cases
return false, nil
}
// For standard 'not' operator
if operator == "not" {
// Handle regular boolean negation
return !ctx.toBool(right), nil
}
switch operator {
case "+":
// Check if both values can be interpreted as numbers first for proper type handling
lNum, lok := ctx.toNumber(left)
rNum, rok := ctx.toNumber(right)
if lok && rok {
// If both can be numbers, perform numeric addition
return lNum + rNum, nil
}
// Otherwise, 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
}
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 "%":
// Modulo operator
if lNum, lok := ctx.toNumber(left); lok {
if rNum, rok := ctx.toNumber(right); rok {
if rNum == 0 {
return nil, errors.New("modulo by zero")
}
return math.Mod(lNum, rNum), nil
}
}
case "^":
// Exponentiation operator
if lNum, lok := ctx.toNumber(left); lok {
if rNum, rok := ctx.toNumber(right); rok {
return math.Pow(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", "&&":
// Note: Short-circuit evaluation is already handled in EvaluateExpression
// This is just the final boolean combination
return ctx.toBool(left) && ctx.toBool(right), nil
case "or", "||":
// Note: Short-circuit evaluation is already handled in EvaluateExpression
// This is just the final boolean combination
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)
// Check for flags in the pattern
caseInsensitive := false
if len(pattern) >= 3 && pattern[0] == '/' {
// Check for /pattern/i format (i after the slash)
if len(pattern) >= 4 && pattern[len(pattern)-1] == 'i' && pattern[len(pattern)-2] == '/' {
caseInsensitive = true
pattern = pattern[1 : len(pattern)-2]
} else if pattern[len(pattern)-1] == '/' {
// Check for /pattern/i format (i before the slash)
if len(pattern) >= 4 && pattern[len(pattern)-2] == 'i' {
caseInsensitive = true
pattern = pattern[1 : len(pattern)-2]
} else {
// Regular /pattern/ without flags
pattern = pattern[1 : len(pattern)-1]
}
}
}
// Handle escaped character sequences
pattern = strings.ReplaceAll(pattern, "\\\\", "\\")
// Special handling for regex character classes
// When working with backslashes in strings, we need 2 levels of escaping
// 1. In Go source, \d is written as \\d
// 2. After string processing, we need to handle it specially
pattern = strings.ReplaceAll(pattern, "\\d", "[0-9]") // digits
pattern = strings.ReplaceAll(pattern, "\\w", "[a-zA-Z0-9_]") // word chars
pattern = strings.ReplaceAll(pattern, "\\s", "[ \\t\\n\\r]") // whitespace
// Compile the regex with appropriate flags
var regex *regexp.Regexp
var err error
if caseInsensitive {
regex, err = regexp.Compile("(?i)" + pattern)
} else {
regex, err = regexp.Compile(pattern)
}
if err != nil {
return false, fmt.Errorf("invalid regular expression: %s", err)
}
result := regex.MatchString(str)
// Add debug logging for the regex matches
if IsDebugEnabled() {
LogDebug("Regex match: pattern=%q, text=%q, result=%v", pattern, str, result)
}
return result, 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
}
// Fast path for common types
switch c := container.(type) {
case string:
// Use string conversion only once
return strings.Contains(c, ctx.ToString(item)), nil
case []interface{}:
// For small slices, linear search is fine
// For larger slices (>50 items), consider a map-based approach
if len(c) > 50 {
// Create a temporary map for O(1) lookups
// Only worth doing for sufficiently large slices
tempMap := make(map[interface{}]struct{}, len(c))
for _, v := range c {
tempMap[v] = struct{}{}
}
// For numeric items, try direct lookup first
if _, ok := tempMap[item]; ok {
return true, nil
}
// For string-comparable items, try string version
if _, ok := tempMap[ctx.ToString(item)]; ok {
return true, nil
}
// Fall back to deep equality comparison
for k := range tempMap {
if ctx.equals(k, item) {
return true, nil
}
}
return false, nil
}
// For small slices, linear search
for _, v := range c {
if ctx.equals(v, item) {
return true, nil
}
}
return false, nil
case map[string]interface{}:
// Convert only once for map key lookup
itemStr := ctx.ToString(item)
_, exists := c[itemStr]
return exists, nil
}
// Handle other types via reflection
rv := reflect.ValueOf(container)
switch rv.Kind() {
case reflect.String:
return strings.Contains(rv.String(), ctx.ToString(item)), nil
case reflect.Array, reflect.Slice:
// Optimize for large slices/arrays
if rv.Len() > 50 {
// Same map-based optimization as above
tempMap := make(map[interface{}]struct{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
tempMap[rv.Index(i).Interface()] = struct{}{}
}
// Try direct lookup
if _, ok := tempMap[item]; ok {
return true, nil
}
// Try string-based lookup
if _, ok := tempMap[ctx.ToString(item)]; ok {
return true, nil
}
// Fall back to equality comparison
for k := range tempMap {
if ctx.equals(k, item) {
return true, nil
}
}
return false, nil
}
// For small collections, linear search
for i := 0; i < rv.Len(); i++ {
if ctx.equals(rv.Index(i).Interface(), item) {
return true, nil
}
}
case reflect.Map:
// For maps, we're already doing key lookup which is O(1)
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)
}