diff --git a/docs/wiki/Template-Expressions.md b/docs/wiki/Template-Expressions.md index b7ec3e1..58ffc05 100644 --- a/docs/wiki/Template-Expressions.md +++ b/docs/wiki/Template-Expressions.md @@ -26,6 +26,26 @@ Use `{{variable}}` syntax to insert data values into text and properties: # Combined path and index - type: text content: "{{orders[0].items[2].name}}" + +# Computed key access (dynamic key from variable) +- type: text + content: "{{translations[lang]}}" + +# String literal key +- type: text + content: "{{translations[\"en\"]}}" + +# Chained access +- type: text + content: "{{sections[current].title}}" + +# Nested computed access +- type: text + content: "{{dict[keys[0]]}}" + +# Expression as key +- type: text + content: "Item: {{arr[base + offset]}}" ``` Variables can be used in **all** element properties -- including typed properties like numbers (`opacity`, `maxLines`, `size`), booleans (`wrap`, `showText`), and enums (`align`, `display`, `position`). When a typed property contains `{{`, the value is preserved as an expression during parsing, resolved at render time, and then parsed into the target type. @@ -243,7 +263,8 @@ Operators are evaluated in this order (highest to lowest): | Precedence | Operators | |------------|-----------| -| 1 (highest) | Logical NOT (`!x`), Unary minus (`-x`) | +| 0 (highest) | Index access (`[]`), Member access (`.`) | +| 1 | Logical NOT (`!x`), Unary minus (`-x`) | | 2 | Multiplication, Division (`*`, `/`) | | 3 | Addition, Subtraction (`+`, `-`) | | 4 | Comparison (`==`, `!=`, `<`, `>`, `<=`, `>=`) | @@ -364,13 +385,14 @@ Conditions support full expressions including comparison operators, logical NOT, {{#each arrayPath}}...{{/each}} ``` -Iterates over an array. Inside the loop body, the current item's properties are accessible directly. Loop variables: +Iterates over an array or object. Inside the loop body, the current item's properties are accessible directly. Loop variables: | Variable | Type | Description | |----------|------|-------------| | `@index` | number | 0-based iteration index | | `@first` | bool | `true` for the first item | | `@last` | bool | `true` for the last item | +| `@key` | string | Key name when iterating over an object (null for arrays) | ```yaml - type: text @@ -379,6 +401,20 @@ Iterates over an array. Inside the loop body, the current item's properties are # Output with items=[{name:"A"},{name:"B"},{name:"C"}]: "A, B, C." ``` +```yaml +# Iterate over object key-value pairs +- type: text + content: "{{#each specs}}{{@key}}: {{.}}, {{/each}}" + +# Output with specs={"Color":"Red","Size":"XL"}: "Color: Red, Size: XL, " + +# Access nested properties during object iteration +- type: text + content: "{{#each people}}{{@key}} is {{age}}, {{/each}}" + +# Output with people={"alice":{"age":30},"bob":{"age":25}}: "alice is 30, bob is 25, " +``` + ### Nesting Text blocks can be nested. The maximum nesting depth is controlled by `ResourceLimits.MaxTemplateNestingDepth` (default: 100). @@ -443,7 +479,7 @@ var data = new ObjectValue ## Loops (type: each) -The `each` element iterates over an array in the data, creating child elements for each item. +The `each` element iterates over an array or object in the data, creating child elements for each item. ```yaml - type: each @@ -458,7 +494,7 @@ The `each` element iterates over an array in the data, creating child elements f | Property | Type | Required | Description | |----------|------|----------|-------------| -| `array` | string | Yes | Path to array in data (e.g., `"items"`, `"order.lines"`) | +| `array` | string | Yes | Path to array or object in data (e.g., `"items"`, `"order.lines"`) | | `as` | string | No | Variable name for each item (default: items are accessible at root) | | `children` | element[] | Yes | Template elements to render per item | @@ -471,6 +507,7 @@ Inside `each` children, these special variables are available: | `{{@index}}` | int | Zero-based index of current item | | `{{@first}}` | bool | `true` for the first item | | `{{@last}}` | bool | `true` for the last item | +| `{{@key}}` | string | Key name when iterating over an object (`null` for arrays) | ### Loop Examples @@ -534,6 +571,51 @@ Inside `each` children, these special variables are available: content: "{{line.qty}} x {{line.unitPrice}}" ``` +**Dictionary iteration (object key-value pairs):** + +```yaml +# Data: {"specs": {"Color": "Red", "Size": "XL", "Material": "Cotton"}} + +- type: each + array: specs + as: val + children: + - type: flex + direction: row + children: + - type: text + content: "{{@key}}:" + - type: text + content: "{{val}}" +``` + +**Cross-dictionary lookup with @key:** + +```yaml +# Data: {"labels": {"name": "Name", "price": "Price"}, "values": {"name": "Widget", "price": "$9.99"}} + +- type: each + array: labels + as: label + children: + - type: text + content: "{{label}}: {{values[@key]}}" +``` + +**Nested object values:** + +```yaml +# Data: {"sections": {"header": {"title": "Hello", "color": "#000"}}} + +- type: each + array: sections + as: section + children: + - type: text + content: "{{@key}}: {{section.title}}" + color: "{{section.color}}" +``` + --- ## Conditionals (type: if) diff --git a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs index fcb1f9a..e444495 100644 --- a/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs @@ -56,7 +56,18 @@ public static TemplateValue Resolve(string path, TemplateContext context) return ResolveLoopVariable(path, context); } - return ResolvePath(path, context.CurrentScope); + // Try current scope first, then walk up parent scopes + var scopes = context.Scopes; + for (var i = scopes.Count - 1; i >= 0; i--) + { + var result = ResolvePath(path, scopes[i]); + if (result is not NullValue) + { + return result; + } + } + + return NullValue.Instance; } private static TemplateValue ResolveLoopVariable(string path, TemplateContext context) @@ -68,6 +79,9 @@ private static TemplateValue ResolveLoopVariable(string path, TemplateContext co : NullValue.Instance, "@first" => new BoolValue(context.IsFirst), "@last" => new BoolValue(context.IsLast), + "@key" => context.LoopKey is not null + ? new StringValue(context.LoopKey) + : NullValue.Instance, _ => NullValue.Instance }; } diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs index 0385bc8..25fbba3 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs @@ -152,3 +152,12 @@ public sealed record LogicalOrExpression(InlineExpression Left, InlineExpression /// The left expression. /// The right expression. public sealed record LogicalAndExpression(InlineExpression Left, InlineExpression Right) : InlineExpression; + +/// +/// A computed index/key access expression (e.g., dict[lang], arr[idx]). +/// Evaluates and uses the result as a key (for ) +/// or numeric index (for ). +/// +/// The expression being indexed (the object or array). +/// The expression whose result is used as the key or index. +public sealed record IndexAccessExpression(InlineExpression Target, InlineExpression Index) : InlineExpression; diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs index ef4ea6d..f2aa3a8 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs @@ -88,6 +88,7 @@ public TemplateValue Evaluate(InlineExpression expression, TemplateContext conte FilterExpression filter => EvaluateFilter(filter, context), NegateExpression neg => EvaluateNegate(neg, context), NotExpression not => EvaluateNot(not, context), + IndexAccessExpression indexAccess => EvaluateIndexAccess(indexAccess, context), _ => NullValue.Instance }; } @@ -243,6 +244,30 @@ private static bool CompareResult(int cmp, ComparisonOperator op) }; } + private TemplateValue EvaluateIndexAccess(IndexAccessExpression expr, TemplateContext context) + { + var obj = Evaluate(expr.Target, context); + var index = Evaluate(expr.Index, context); + + return (obj, index) switch + { + (ObjectValue objVal, StringValue strKey) => objVal[strKey.Value], + (ObjectValue objVal, NumberValue numKey) => objVal[numKey.Value.ToString("G", CultureInfo.InvariantCulture)], + (ArrayValue arrVal, NumberValue numIdx) => EvaluateArrayIndex(arrVal, numIdx), + _ => NullValue.Instance + }; + } + + private static TemplateValue EvaluateArrayIndex(ArrayValue array, NumberValue index) + { + var idx = (int)Math.Truncate(index.Value); + if (idx < 0 || idx >= array.Count || idx > ExpressionEvaluator.MaxArrayIndex) + { + return NullValue.Instance; + } + return array[idx]; + } + private BoolValue EvaluateNot(NotExpression expr, TemplateContext context) { var operand = Evaluate(expr.Operand, context); diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs index 63a5345..940eaed 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs @@ -7,7 +7,8 @@ namespace FlexRender.TemplateEngine; /// /// Pratt parser for inline expressions within {{...}} blocks. /// Supports arithmetic operators, comparison operators, logical NOT, logical OR (||), -/// logical AND (&&), null coalesce, filter pipes, and parenthesized grouping. +/// logical AND (&&), null coalesce, filter pipes, computed index access ([]), +/// and parenthesized grouping. /// /// /// Operator precedence (lowest to highest): @@ -20,6 +21,7 @@ namespace FlexRender.TemplateEngine; /// +, - (add, subtract) /// *, / (multiply, divide) /// Unary - (negation), ! (logical NOT) +/// [] (computed index/key access), . (member access after index) /// () (grouping) /// /// @@ -94,6 +96,17 @@ public static bool NeedsFullParsing(string content) // and rely on the regex for simple path detection below } + // Check for computed key access: [non-digit] requires full parsing + // Simple numeric indices like items[0] do not need full parsing + if (content.Contains('[')) + { + var bracketIdx = content.IndexOf('['); + if (bracketIdx + 1 < content.Length && !char.IsDigit(content[bracketIdx + 1])) + { + return true; + } + } + // Check if it's a simple path (no operators) // Minus in paths is not an operator: "my-var" is a valid path if (content.Contains('-')) @@ -326,6 +339,8 @@ private InlineExpression ParseInfix(InlineExpression left, Precedence precedence '-' => ParseArithmetic(left, ArithmeticOperator.Subtract), '*' => ParseArithmetic(left, ArithmeticOperator.Multiply), '/' => ParseArithmetic(left, ArithmeticOperator.Divide), + '[' => ParseIndexAccess(left), + '.' => ParseMemberAccess(left), _ => throw new TemplateEngineException( $"Unexpected operator '{c}'", position: _pos, @@ -641,7 +656,7 @@ private InlineExpression ParsePath() { var c = _input[_pos]; - if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '[' || c == ']' || c == '@') + if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '@') { _pos++; continue; @@ -697,10 +712,60 @@ private InlineExpression ParsePath() '>' => (Precedence.Comparison, false), '+' or '-' => (Precedence.Additive, false), '*' or '/' => (Precedence.Multiplicative, false), + '[' => (Precedence.Postfix, false), + '.' when _pos > 0 => (Precedence.Postfix, false), _ => (Precedence.None, false) }; } + private IndexAccessExpression ParseIndexAccess(InlineExpression left) + { + _pos++; // skip [ + var index = ParseExpression(Precedence.None); + SkipWhitespace(); + if (_pos >= _input.Length || _input[_pos] != ']') + { + throw new TemplateEngineException( + "Missing closing bracket ']'", + position: _pos, + expression: _input); + } + _pos++; // skip ] + return new IndexAccessExpression(left, index); + } + + private IndexAccessExpression ParseMemberAccess(InlineExpression left) + { + _pos++; // skip . + var start = _pos; + while (_pos < _input.Length) + { + var c = _input[_pos]; + if (char.IsLetterOrDigit(c) || c == '_' || c == '@') + { + _pos++; + continue; + } + // Allow hyphens in property names (same rules as ParsePath) + if (c == '-' && _pos + 1 < _input.Length && !char.IsWhiteSpace(_input[_pos + 1]) + && _pos > start && !char.IsWhiteSpace(_input[_pos - 1])) + { + _pos++; + continue; + } + break; + } + if (_pos == start) + { + throw new TemplateEngineException( + "Expected property name after '.'", + position: _pos, + expression: _input); + } + var propName = _input[start.._pos]; + return new IndexAccessExpression(left, new StringLiteral(propName)); + } + private bool IsDoubleChar(char c) { return _pos + 1 < _input.Length && _input[_pos] == c && _input[_pos + 1] == c; @@ -724,6 +789,7 @@ private enum Precedence Comparison = 5, Additive = 6, Multiplicative = 7, - Unary = 8 + Unary = 8, + Postfix = 9 } } diff --git a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs index 74bad3c..9458761 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateContext.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateContext.cs @@ -12,6 +12,18 @@ public sealed class TemplateContext /// public TemplateValue CurrentScope => _scopeStack.Peek(); + /// + /// Gets a read-only view of the scope stack for scope walking during variable resolution. + /// The first element (index 0) is the root scope, the last element is the current (innermost) scope. + /// + internal IReadOnlyList Scopes => _scopeList; + + /// + /// Cached list view of the scope stack for efficient scope walking. + /// Kept in sync with the stack via PushScope/PopScope. + /// + private readonly List _scopeList = []; + /// /// Gets the current loop index, or null if not in a loop. /// @@ -27,6 +39,11 @@ public sealed class TemplateContext /// public bool IsLast { get; private set; } + /// + /// Gets the current loop key when iterating over an ObjectValue, or null if not in an object loop. + /// + public string? LoopKey { get; private set; } + /// /// Initializes a new context with root data. /// @@ -36,6 +53,7 @@ public TemplateContext(TemplateValue rootData) { ArgumentNullException.ThrowIfNull(rootData); _scopeStack.Push(rootData); + _scopeList.Add(rootData); } /// @@ -47,6 +65,7 @@ public void PushScope(TemplateValue scope) { ArgumentNullException.ThrowIfNull(scope); _scopeStack.Push(scope); + _scopeList.Add(scope); } /// @@ -60,6 +79,7 @@ public void PopScope() throw new InvalidOperationException("Cannot pop the root scope."); } _scopeStack.Pop(); + _scopeList.RemoveAt(_scopeList.Count - 1); } /// @@ -93,6 +113,17 @@ public void SetLoopVariables(int index, int count) IsLast = index == count - 1; } + /// + /// Sets the loop key for the current object iteration. + /// + /// The current key. Must not be null. + /// Thrown when is null. + public void SetLoopKey(string key) + { + ArgumentNullException.ThrowIfNull(key); + LoopKey = key.Trim(); + } + /// /// Clears all loop variables. /// @@ -101,5 +132,6 @@ public void ClearLoopVariables() LoopIndex = null; IsFirst = false; IsLast = false; + LoopKey = null; } } diff --git a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs index 426637f..5bb2f41 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs @@ -137,45 +137,85 @@ private IEnumerable ExpandEach(EachElement each, TemplateContex var arrayValue = ExpressionEvaluator.Resolve(each.ArrayPath, context); - if (arrayValue is not ArrayValue array) + if (arrayValue is ArrayValue array) { - yield break; - } + // Existing array iteration logic + var count = array.Count; + for (var i = 0; i < count; i++) + { + var item = array[i]; - var count = array.Count; - for (var i = 0; i < count; i++) - { - var item = array[i]; + // Push item scope + if (each.ItemVariable != null) + { + // Create a new scope with the item variable + var scopeData = new ObjectValue + { + [each.ItemVariable] = item + }; + context.PushScope(scopeData); + } + else + { + context.PushScope(item); + } - // Push item scope - if (each.ItemVariable != null) - { - // Create a new scope with the item variable - var scopeData = new ObjectValue + context.SetLoopVariables(i, count); + + // Expand children + var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); + + // Pop scope + context.ClearLoopVariables(); + context.PopScope(); + + foreach (var child in expandedChildren) { - [each.ItemVariable] = item - }; - context.PushScope(scopeData); + yield return child; + } } - else + } + else if (arrayValue is ObjectValue obj) + { + // Object iteration: iterate over key-value pairs with @key support + var keys = obj.Keys.ToList(); + var count = keys.Count; + for (var i = 0; i < count; i++) { - context.PushScope(item); - } + var key = keys[i]; + var value = obj[key]; + + // Push item scope + if (each.ItemVariable != null) + { + var scopeData = new ObjectValue + { + [each.ItemVariable] = value + }; + context.PushScope(scopeData); + } + else + { + context.PushScope(value); + } - context.SetLoopVariables(i, count); + context.SetLoopVariables(i, count); + context.SetLoopKey(key); - // Expand children - var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); + // Expand children + var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth); - // Pop scope - context.ClearLoopVariables(); - context.PopScope(); + // Pop scope + context.ClearLoopVariables(); + context.PopScope(); - foreach (var child in expandedChildren) - { - yield return child; + foreach (var child in expandedChildren) + { + yield return child; + } } } + // else: not array or object -- yield nothing (implicit yield break) } private void ValidateNestedDepth(IReadOnlyList elements, int depth) diff --git a/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs b/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs index eaa0a66..8b60e48 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateProcessor.cs @@ -256,6 +256,22 @@ private int ProcessEachBlock(List tokens, int startIndex, Templ context.PopScope(); } } + else if (arrayValue is ObjectValue obj) + { + var keys = obj.Keys.ToList(); + for (var i = 0; i < keys.Count; i++) + { + context.PushScope(obj[keys[i]]); + context.SetLoopVariables(i, keys.Count); + context.SetLoopKey(keys[i]); + + // bodyTokens is reused across iterations - ProcessTokens only reads the list + result.Append(ProcessTokens(bodyTokens, context, newDepth)); + + context.ClearLoopVariables(); + context.PopScope(); + } + } return endIndex + 1; } diff --git a/src/FlexRender.Core/Values/ObjectValue.cs b/src/FlexRender.Core/Values/ObjectValue.cs index 657c38c..f14d6e9 100644 --- a/src/FlexRender.Core/Values/ObjectValue.cs +++ b/src/FlexRender.Core/Values/ObjectValue.cs @@ -16,8 +16,8 @@ public sealed class ObjectValue : TemplateValue /// Thrown when is null on set. public TemplateValue this[string key] { - get => _properties.GetValueOrDefault(key, NullValue.Instance); - set => _properties[key] = value ?? throw new ArgumentNullException(nameof(value)); + get => _properties.GetValueOrDefault(key.Trim(), NullValue.Instance); + set => _properties[key.Trim()] = value ?? throw new ArgumentNullException(nameof(value)); } /// @@ -35,7 +35,7 @@ public TemplateValue this[string key] /// /// The key to check. /// True if the property exists; otherwise, false. - public bool ContainsKey(string key) => _properties.ContainsKey(key); + public bool ContainsKey(string key) => _properties.ContainsKey(key.Trim()); /// /// Tries to get the value of a property. @@ -45,7 +45,7 @@ public TemplateValue this[string key] /// True if the property was found; otherwise, false. public bool TryGetValue(string key, out TemplateValue? value) { - return _properties.TryGetValue(key, out value); + return _properties.TryGetValue(key.Trim(), out value); } /// diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs index 11c097e..0192b95 100644 --- a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs +++ b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs @@ -432,6 +432,186 @@ public void Evaluate_UnknownFilter_ThrowsTemplateEngineException() () => Evaluator.Evaluate(ast, context)); } + // === Index Access (Computed Key) === + + [Fact] + public void Evaluate_IndexAccess_StringKey_ReturnsValue() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["en"] = new StringValue("Hello"), ["ru"] = new StringValue("Привет") }, + ["lang"] = new StringValue("ru") + }; + var result = Evaluate("dict[lang]", data); + var str = Assert.IsType(result); + Assert.Equal("Привет", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_StringLiteral_ReturnsValue() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["key"] = new StringValue("value") } + }; + var result = Evaluate("dict[\"key\"]", data); + var str = Assert.IsType(result); + Assert.Equal("value", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_NumericIndex_ReturnsArrayItem() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a"), new StringValue("b"), new StringValue("c") }), + ["idx"] = new NumberValue(1) + }; + var result = Evaluate("arr[idx]", data); + var str = Assert.IsType(result); + Assert.Equal("b", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_MissingKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") }, + ["key"] = new StringValue("missing") + }; + var result = Evaluate("dict[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_NullKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") } + }; + var result = Evaluate("dict[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_BoolKey_ReturnsNull() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["a"] = new StringValue("1") }, + ["flag"] = new BoolValue(true) + }; + var result = Evaluate("dict[flag]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_NumberKeyOnObject_ConvertsToString() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["42"] = new StringValue("answer") }, + ["num"] = new NumberValue(42) + }; + var result = Evaluate("dict[num]", data); + var str = Assert.IsType(result); + Assert.Equal("answer", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_StringKeyOnArray_ReturnsNull() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a") }), + ["key"] = new StringValue("notAnIndex") + }; + var result = Evaluate("arr[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_OnStringValue_ReturnsNull() + { + var data = new ObjectValue + { + ["str"] = new StringValue("hello"), + ["key"] = new StringValue("x") + }; + var result = Evaluate("str[key]", data); + Assert.IsType(result); + } + + [Fact] + public void Evaluate_IndexAccess_Chained_Works() + { + var data = new ObjectValue + { + ["sections"] = new ObjectValue + { + ["header"] = new ObjectValue { ["title"] = new StringValue("Hello") } + }, + ["current"] = new StringValue("header") + }; + var result = Evaluate("sections[current].title", data); + var str = Assert.IsType(result); + Assert.Equal("Hello", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_Nested_Works() + { + var data = new ObjectValue + { + ["dict"] = new ObjectValue { ["en"] = new StringValue("Hello") }, + ["keys"] = new ArrayValue(new List { new StringValue("en"), new StringValue("ru") }) + }; + var result = Evaluate("dict[keys[0]]", data); + var str = Assert.IsType(result); + Assert.Equal("Hello", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_WithArithmeticExpression_Works() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("zero"), new StringValue("one"), new StringValue("two") }), + ["base"] = new NumberValue(1), + ["offset"] = new NumberValue(1) + }; + var result = Evaluate("arr[base + offset]", data); + var str = Assert.IsType(result); + Assert.Equal("two", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_FractionalIndex_TruncatesToInt() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("zero"), new StringValue("one") }), + ["idx"] = new NumberValue(1.7m) + }; + var result = Evaluate("arr[idx]", data); + var str = Assert.IsType(result); + Assert.Equal("one", str.Value); + } + + [Fact] + public void Evaluate_IndexAccess_NegativeIndex_ReturnsNull() + { + var data = new ObjectValue + { + ["arr"] = new ArrayValue(new List { new StringValue("a") }), + ["idx"] = new NumberValue(-1) + }; + var result = Evaluate("arr[idx]", data); + Assert.IsType(result); + } + // === Named filter parameters === [Fact] diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs index ef2d2cb..40b456c 100644 --- a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs +++ b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs @@ -954,6 +954,108 @@ public void Parse_TruePrefix_IsPath() #endregion + #region Index Access (Computed Key) + + [Fact] + public void Parse_VariableKeyAccess_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[lang]"); + var access = Assert.IsType(result); + var obj = Assert.IsType(access.Target); + Assert.Equal("dict", obj.Path); + var idx = Assert.IsType(access.Index); + Assert.Equal("lang", idx.Path); + } + + [Fact] + public void Parse_StringLiteralKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[\"key\"]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + var idx = Assert.IsType(access.Index); + Assert.Equal("key", idx.Value); + } + + [Fact] + public void Parse_NumericKeyInFullParser_ProducesIndexAccess() + { + // Force through full parser by adding a variable key expression context + var result = InlineExpressionParser.Parse("arr[0]"); + // This should go through fast path and produce PathExpression + // Only when [non-digit] is present does it go through full parser + var path = Assert.IsType(result); + Assert.Equal("arr[0]", path.Path); + } + + [Fact] + public void Parse_ExpressionKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[a + b]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + Assert.IsType(access.Index); + } + + [Fact] + public void Parse_FilterKey_ProducesIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[key | lower]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + Assert.IsType(access.Index); + } + + [Fact] + public void Parse_NestedBrackets_ProducesNestedIndexAccess() + { + var result = InlineExpressionParser.Parse("dict[arr[0]]"); + var outer = Assert.IsType(result); + Assert.IsType(outer.Target); + var inner = Assert.IsType(outer.Index); + Assert.IsType(inner.Target); + Assert.IsType(inner.Index); + } + + [Fact] + public void Parse_ChainedAccess_Works() + { + var result = InlineExpressionParser.Parse("obj[k].prop"); + // obj[k] -> IndexAccessExpression, then .prop -> IndexAccessExpression with StringLiteral + var outer = Assert.IsType(result); + var inner = Assert.IsType(outer.Target); + Assert.IsType(inner.Target); + Assert.IsType(inner.Index); + var propKey = Assert.IsType(outer.Index); + Assert.Equal("prop", propKey.Value); + } + + [Fact] + public void Parse_LoopVarKey_Works() + { + var result = InlineExpressionParser.Parse("dict[@key]"); + var access = Assert.IsType(result); + Assert.IsType(access.Target); + var idx = Assert.IsType(access.Index); + Assert.Equal("@key", idx.Path); + } + + [Fact] + public void Parse_SimpleNumericIndex_StillWorksThroughFastPath() + { + var result = InlineExpressionParser.Parse("items[0].price"); + var path = Assert.IsType(result); + Assert.Equal("items[0].price", path.Path); + } + + [Fact] + public void Parse_MissingCloseBracket_ThrowsTemplateEngineException() + { + Assert.Throws(() => InlineExpressionParser.Parse("dict[lang")); + } + + #endregion + #region Named Filter Parameters [Fact] diff --git a/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs b/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs index db68111..aefbbbd 100644 --- a/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/ExpressionEvaluatorTests.cs @@ -326,4 +326,42 @@ public void Resolve_CurrentScope_ReturnsScope() Assert.Same(data, result); } + + [Fact] + public void Resolve_LoopKey_ReturnsStringValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + context.SetLoopVariables(0, 3); + context.SetLoopKey("myKey"); + + var result = ExpressionEvaluator.Resolve("@key", context); + + var str = Assert.IsType(result); + Assert.Equal("myKey", str.Value); + } + + [Fact] + public void Resolve_LoopKey_OutsideLoop_ReturnsNullValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + + var result = ExpressionEvaluator.Resolve("@key", context); + + Assert.IsType(result); + } + + [Fact] + public void Resolve_LoopKey_InArrayLoop_ReturnsNullValue() + { + var data = new ObjectValue { ["x"] = "test" }; + var context = new TemplateContext(data); + context.SetLoopVariables(0, 3); + // LoopKey not set — simulates array iteration + + var result = ExpressionEvaluator.Resolve("@key", context); + + Assert.IsType(result); + } } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs index 8ad2505..7034492 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateContextTests.cs @@ -161,4 +161,60 @@ public void SetLoopVariables_ValidParameters_Succeeds() Assert.Null(exception); } + + // LoopKey Tests + [Fact] + public void LoopKey_Default_IsNull() + { + var context = new TemplateContext(new ObjectValue()); + Assert.Null(context.LoopKey); + } + + [Fact] + public void SetLoopKey_SetsValue() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey"); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void ClearLoopVariables_ClearsLoopKey() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey"); + context.ClearLoopVariables(); + Assert.Null(context.LoopKey); + } + + [Fact] + public void SetLoopKey_Null_ThrowsArgumentNullException() + { + var context = new TemplateContext(new ObjectValue()); + Assert.Throws(() => context.SetLoopKey(null!)); + } + + [Fact] + public void SetLoopKey_TrimsWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey(" myKey "); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void SetLoopKey_TrimsLeadingWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey(" myKey"); + Assert.Equal("myKey", context.LoopKey); + } + + [Fact] + public void SetLoopKey_TrimsTrailingWhitespace() + { + var context = new TemplateContext(new ObjectValue()); + context.SetLoopKey("myKey "); + Assert.Equal("myKey", context.LoopKey); + } } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs index 7685fa7..d8ae226 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateExpanderTests.cs @@ -698,4 +698,181 @@ public void Expand_TextWithMissingVariable_ReplacesWithEmpty() var text = Assert.IsType(result.Elements[0]); Assert.Equal("Hello ", text.Content); } + + // === ObjectValue Iteration Tests === + + [Fact] + public void Expand_EachWithObjectValue_IteratesKeyValuePairs() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red"), + ["Size"] = new StringValue("XL") + } + }; + var textTemplate = new TextElement { Content = "{{@key}}: {{.}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(2, result.Elements.Count); + var first = Assert.IsType(result.Elements[0]); + Assert.Equal("Color: Red", first.Content.Value); + var second = Assert.IsType(result.Elements[1]); + Assert.Equal("Size: XL", second.Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_WithAsVariable_BindsValue() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red") + } + }; + var textTemplate = new TextElement { Content = "{{@key}}={{val}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs", + ItemVariable = "val" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Single(result.Elements); + var text = Assert.IsType(result.Elements[0]); + Assert.Equal("Color=Red", text.Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_IndexFirstLast() + { + var data = new ObjectValue + { + ["items"] = new ObjectValue + { + ["a"] = new StringValue("1"), + ["b"] = new StringValue("2"), + ["c"] = new StringValue("3") + } + }; + var textTemplate = new TextElement { Content = "{{@index}}-{{@first}}-{{@last}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "items" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(3, result.Elements.Count); + Assert.Equal("0-true-false", ((TextElement)result.Elements[0]).Content.Value); + Assert.Equal("1-false-false", ((TextElement)result.Elements[1]).Content.Value); + Assert.Equal("2-false-true", ((TextElement)result.Elements[2]).Content.Value); + } + + [Fact] + public void Expand_EachWithObjectValue_NestedValues() + { + var data = new ObjectValue + { + ["sections"] = new ObjectValue + { + ["header"] = new ObjectValue { ["title"] = new StringValue("Hello") } + } + }; + var textTemplate = new TextElement { Content = "{{@key}}: {{val.title}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "sections", + ItemVariable = "val" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Single(result.Elements); + var text = Assert.IsType(result.Elements[0]); + Assert.Equal("header: Hello", text.Content.Value); + } + + [Fact] + public void Expand_EachWithEmptyObjectValue_ReturnsEmpty() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue() + }; + var textTemplate = new TextElement { Content = "{{@key}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Empty(result.Elements); + } + + [Fact] + public void Expand_EachWithStringValue_ReturnsEmpty() + { + var data = new ObjectValue + { + ["specs"] = new StringValue("not iterable") + }; + var textTemplate = new TextElement { Content = "{{.}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "specs" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Empty(result.Elements); + } + + [Fact] + public void Expand_EachOverObject_WithComputedKeyAccess() + { + // Scenario: iterate over labels dict, look up values from another dict by @key + var data = new ObjectValue + { + ["labels"] = new ObjectValue + { + ["name"] = new StringValue("Name"), + ["price"] = new StringValue("Price") + }, + ["values"] = new ObjectValue + { + ["name"] = new StringValue("Widget"), + ["price"] = new StringValue("$9.99") + } + }; + + var textTemplate = new TextElement { Content = "{{label}}: {{values[@key]}}" }; + var each = new EachElement(new List { textTemplate }) + { + ArrayPath = "labels", + ItemVariable = "label" + }; + var template = CreateTemplate(each); + + var result = _expander.Expand(template, data); + + Assert.Equal(2, result.Elements.Count); + Assert.Equal("Name: Widget", ((TextElement)result.Elements[0]).Content.Value); + Assert.Equal("Price: $9.99", ((TextElement)result.Elements[1]).Content.Value); + } } diff --git a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs index dee6d62..d2a524b 100644 --- a/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs +++ b/tests/FlexRender.Tests/TemplateEngine/TemplateProcessorIntegrationTests.cs @@ -270,4 +270,109 @@ public void ProcessIfBlock_NullCoalesceWithSingleQuoteFallback_Works() Assert.Equal("Welcome guest", result); } + + /// + /// Verifies that inline {{#each}} iterates over ObjectValue key-value pairs, + /// exposing @key and . (current scope) for each entry. + /// + [Fact] + public void Process_InlineEachOverObject_IteratesKeyValuePairs() + { + var data = new ObjectValue + { + ["specs"] = new ObjectValue + { + ["Color"] = new StringValue("Red"), + ["Size"] = new StringValue("XL") + } + }; + + var result = _processor.Process("{{#each specs}}{{@key}}:{{.}} {{/each}}", data); + + Assert.Equal("Color:Red Size:XL ", result); + } + + /// + /// Verifies that inline {{#each}} over an ObjectValue allows accessing nested + /// properties on each value when values are themselves objects. + /// + [Fact] + public void Process_InlineEachOverObject_NestedProperty() + { + var data = new ObjectValue + { + ["people"] = new ObjectValue + { + ["alice"] = new ObjectValue { ["age"] = new NumberValue(30) }, + ["bob"] = new ObjectValue { ["age"] = new NumberValue(25) } + } + }; + + var result = _processor.Process("{{#each people}}{{@key}}={{age}} {{/each}}", data); + + Assert.Equal("alice=30 bob=25 ", result); + } + + /// + /// Verifies that inline {{#each}} over an empty ObjectValue produces no output. + /// + [Fact] + public void Process_InlineEachOverObject_Empty_ReturnsEmpty() + { + var data = new ObjectValue + { + ["obj"] = new ObjectValue() + }; + + var result = _processor.Process("before{{#each obj}}{{@key}}{{/each}}after", data); + + Assert.Equal("beforeafter", result); + } + + /// + /// Verifies that inline {{#each}} over an ObjectValue correctly sets + /// @index, @first, and @last loop variables. + /// + [Fact] + public void Process_InlineEachOverObject_IndexFirstLast() + { + var data = new ObjectValue + { + ["items"] = new ObjectValue + { + ["a"] = new StringValue("1"), + ["b"] = new StringValue("2") + } + }; + + var result = _processor.Process("{{#each items}}{{@index}}{{@first}}{{@last}} {{/each}}", data); + + Assert.Equal("0truefalse 1falsetrue ", result); + } + + /// + /// Verifies that inline {{#each}} over an ObjectValue supports computed key access + /// to look up values from another dictionary using @key. + /// + [Fact] + public void Process_InlineEachOverObject_WithComputedKeyAccess() + { + var data = new ObjectValue + { + ["labels"] = new ObjectValue + { + ["name"] = new StringValue("Name"), + ["price"] = new StringValue("Price") + }, + ["values"] = new ObjectValue + { + ["name"] = new StringValue("Widget"), + ["price"] = new StringValue("$9.99") + } + }; + + var result = _processor.Process("{{#each labels}}{{.}}: {{values[@key]}} {{/each}}", data); + + Assert.Equal("Name: Widget Price: $9.99 ", result); + } } diff --git a/tests/FlexRender.Tests/Values/ObjectValueTests.cs b/tests/FlexRender.Tests/Values/ObjectValueTests.cs index dafcd35..2f87480 100644 --- a/tests/FlexRender.Tests/Values/ObjectValueTests.cs +++ b/tests/FlexRender.Tests/Values/ObjectValueTests.cs @@ -194,4 +194,48 @@ public void ToString_ReturnsObjectRepresentation() Assert.Contains("name", result); Assert.Contains("test", result); } + + [Fact] + public void Indexer_Set_TrimsKey() + { + var obj = new ObjectValue(); + obj[" name "] = new StringValue("value"); + Assert.Equal("value", ((StringValue)obj["name"]).Value); + } + + [Fact] + public void Indexer_Get_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + var result = obj[" name "]; + Assert.IsType(result); + Assert.Equal("value", ((StringValue)result).Value); + } + + [Fact] + public void ContainsKey_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + Assert.True(obj.ContainsKey(" name ")); + } + + [Fact] + public void TryGetValue_TrimsKey() + { + var obj = new ObjectValue(); + obj["name"] = new StringValue("value"); + Assert.True(obj.TryGetValue(" name ", out var value)); + Assert.Equal("value", ((StringValue)value!).Value); + } + + [Fact] + public void Keys_ReturnsTrimmedKeys() + { + var obj = new ObjectValue(); + obj[" name "] = new StringValue("value"); + Assert.Contains("name", obj.Keys); + Assert.DoesNotContain(" name ", obj.Keys); + } }