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);
+ }
}