Skip to content

Commit d32a7b2

Browse files
authored
feat: dictionary iteration and computed key access (#3)
* feat(ast): add LoopKey to TemplateContext for dictionary iteration * feat(ast): add @key loop variable to ExpressionEvaluator * feat(ast): add ObjectValue iteration to TemplateExpander * feat(ast): add ObjectValue iteration to inline {{#each}} blocks * feat(ast): trim keys in SetLoopKey to ensure clean @key values * feat(ast): trim keys in ObjectValue for consistent key handling * feat(ast): add computed key access [expr] to expression system Add IndexAccessExpression AST node, Pratt parser postfix operator for bracket notation, and evaluator support for dynamic key/index access on ObjectValue and ArrayValue. * test: add end-to-end integration tests for dictionary iteration with computed key access Add scope walking to ExpressionEvaluator.Resolve so variables from parent scopes (e.g. root data) are accessible inside each loops. This enables patterns like {{values[@key]}} where @key comes from the loop and values lives at root level. * docs: fix inverted XML doc comment on TemplateContext.Scopes * docs: add dictionary iteration and computed key access to wiki
1 parent 109e5fa commit d32a7b2

16 files changed

Lines changed: 1025 additions & 39 deletions

docs/wiki/Template-Expressions.md

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ Use `{{variable}}` syntax to insert data values into text and properties:
2626
# Combined path and index
2727
- type: text
2828
content: "{{orders[0].items[2].name}}"
29+
30+
# Computed key access (dynamic key from variable)
31+
- type: text
32+
content: "{{translations[lang]}}"
33+
34+
# String literal key
35+
- type: text
36+
content: "{{translations[\"en\"]}}"
37+
38+
# Chained access
39+
- type: text
40+
content: "{{sections[current].title}}"
41+
42+
# Nested computed access
43+
- type: text
44+
content: "{{dict[keys[0]]}}"
45+
46+
# Expression as key
47+
- type: text
48+
content: "Item: {{arr[base + offset]}}"
2949
```
3050
3151
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):
243263

244264
| Precedence | Operators |
245265
|------------|-----------|
246-
| 1 (highest) | Logical NOT (`!x`), Unary minus (`-x`) |
266+
| 0 (highest) | Index access (`[]`), Member access (`.`) |
267+
| 1 | Logical NOT (`!x`), Unary minus (`-x`) |
247268
| 2 | Multiplication, Division (`*`, `/`) |
248269
| 3 | Addition, Subtraction (`+`, `-`) |
249270
| 4 | Comparison (`==`, `!=`, `<`, `>`, `<=`, `>=`) |
@@ -364,13 +385,14 @@ Conditions support full expressions including comparison operators, logical NOT,
364385
{{#each arrayPath}}...{{/each}}
365386
```
366387

367-
Iterates over an array. Inside the loop body, the current item's properties are accessible directly. Loop variables:
388+
Iterates over an array or object. Inside the loop body, the current item's properties are accessible directly. Loop variables:
368389

369390
| Variable | Type | Description |
370391
|----------|------|-------------|
371392
| `@index` | number | 0-based iteration index |
372393
| `@first` | bool | `true` for the first item |
373394
| `@last` | bool | `true` for the last item |
395+
| `@key` | string | Key name when iterating over an object (null for arrays) |
374396

375397
```yaml
376398
- type: text
@@ -379,6 +401,20 @@ Iterates over an array. Inside the loop body, the current item's properties are
379401
# Output with items=[{name:"A"},{name:"B"},{name:"C"}]: "A, B, C."
380402
```
381403

404+
```yaml
405+
# Iterate over object key-value pairs
406+
- type: text
407+
content: "{{#each specs}}{{@key}}: {{.}}, {{/each}}"
408+
409+
# Output with specs={"Color":"Red","Size":"XL"}: "Color: Red, Size: XL, "
410+
411+
# Access nested properties during object iteration
412+
- type: text
413+
content: "{{#each people}}{{@key}} is {{age}}, {{/each}}"
414+
415+
# Output with people={"alice":{"age":30},"bob":{"age":25}}: "alice is 30, bob is 25, "
416+
```
417+
382418
### Nesting
383419

384420
Text blocks can be nested. The maximum nesting depth is controlled by `ResourceLimits.MaxTemplateNestingDepth` (default: 100).
@@ -443,7 +479,7 @@ var data = new ObjectValue
443479

444480
## Loops (type: each)
445481

446-
The `each` element iterates over an array in the data, creating child elements for each item.
482+
The `each` element iterates over an array or object in the data, creating child elements for each item.
447483

448484
```yaml
449485
- type: each
@@ -458,7 +494,7 @@ The `each` element iterates over an array in the data, creating child elements f
458494

459495
| Property | Type | Required | Description |
460496
|----------|------|----------|-------------|
461-
| `array` | string | Yes | Path to array in data (e.g., `"items"`, `"order.lines"`) |
497+
| `array` | string | Yes | Path to array or object in data (e.g., `"items"`, `"order.lines"`) |
462498
| `as` | string | No | Variable name for each item (default: items are accessible at root) |
463499
| `children` | element[] | Yes | Template elements to render per item |
464500

@@ -471,6 +507,7 @@ Inside `each` children, these special variables are available:
471507
| `{{@index}}` | int | Zero-based index of current item |
472508
| `{{@first}}` | bool | `true` for the first item |
473509
| `{{@last}}` | bool | `true` for the last item |
510+
| `{{@key}}` | string | Key name when iterating over an object (`null` for arrays) |
474511

475512
### Loop Examples
476513

@@ -534,6 +571,51 @@ Inside `each` children, these special variables are available:
534571
content: "{{line.qty}} x {{line.unitPrice}}"
535572
```
536573

574+
**Dictionary iteration (object key-value pairs):**
575+
576+
```yaml
577+
# Data: {"specs": {"Color": "Red", "Size": "XL", "Material": "Cotton"}}
578+
579+
- type: each
580+
array: specs
581+
as: val
582+
children:
583+
- type: flex
584+
direction: row
585+
children:
586+
- type: text
587+
content: "{{@key}}:"
588+
- type: text
589+
content: "{{val}}"
590+
```
591+
592+
**Cross-dictionary lookup with @key:**
593+
594+
```yaml
595+
# Data: {"labels": {"name": "Name", "price": "Price"}, "values": {"name": "Widget", "price": "$9.99"}}
596+
597+
- type: each
598+
array: labels
599+
as: label
600+
children:
601+
- type: text
602+
content: "{{label}}: {{values[@key]}}"
603+
```
604+
605+
**Nested object values:**
606+
607+
```yaml
608+
# Data: {"sections": {"header": {"title": "Hello", "color": "#000"}}}
609+
610+
- type: each
611+
array: sections
612+
as: section
613+
children:
614+
- type: text
615+
content: "{{@key}}: {{section.title}}"
616+
color: "{{section.color}}"
617+
```
618+
537619
---
538620

539621
## Conditionals (type: if)

src/FlexRender.Core/TemplateEngine/ExpressionEvaluator.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,18 @@ public static TemplateValue Resolve(string path, TemplateContext context)
5656
return ResolveLoopVariable(path, context);
5757
}
5858

59-
return ResolvePath(path, context.CurrentScope);
59+
// Try current scope first, then walk up parent scopes
60+
var scopes = context.Scopes;
61+
for (var i = scopes.Count - 1; i >= 0; i--)
62+
{
63+
var result = ResolvePath(path, scopes[i]);
64+
if (result is not NullValue)
65+
{
66+
return result;
67+
}
68+
}
69+
70+
return NullValue.Instance;
6071
}
6172

6273
private static TemplateValue ResolveLoopVariable(string path, TemplateContext context)
@@ -68,6 +79,9 @@ private static TemplateValue ResolveLoopVariable(string path, TemplateContext co
6879
: NullValue.Instance,
6980
"@first" => new BoolValue(context.IsFirst),
7081
"@last" => new BoolValue(context.IsLast),
82+
"@key" => context.LoopKey is not null
83+
? new StringValue(context.LoopKey)
84+
: NullValue.Instance,
7185
_ => NullValue.Instance
7286
};
7387
}

src/FlexRender.Core/TemplateEngine/InlineExpression.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,12 @@ public sealed record LogicalOrExpression(InlineExpression Left, InlineExpression
152152
/// <param name="Left">The left expression.</param>
153153
/// <param name="Right">The right expression.</param>
154154
public sealed record LogicalAndExpression(InlineExpression Left, InlineExpression Right) : InlineExpression;
155+
156+
/// <summary>
157+
/// A computed index/key access expression (e.g., <c>dict[lang]</c>, <c>arr[idx]</c>).
158+
/// Evaluates <see cref="Index"/> and uses the result as a key (for <see cref="ObjectValue"/>)
159+
/// or numeric index (for <see cref="ArrayValue"/>).
160+
/// </summary>
161+
/// <param name="Target">The expression being indexed (the object or array).</param>
162+
/// <param name="Index">The expression whose result is used as the key or index.</param>
163+
public sealed record IndexAccessExpression(InlineExpression Target, InlineExpression Index) : InlineExpression;

src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public TemplateValue Evaluate(InlineExpression expression, TemplateContext conte
8888
FilterExpression filter => EvaluateFilter(filter, context),
8989
NegateExpression neg => EvaluateNegate(neg, context),
9090
NotExpression not => EvaluateNot(not, context),
91+
IndexAccessExpression indexAccess => EvaluateIndexAccess(indexAccess, context),
9192
_ => NullValue.Instance
9293
};
9394
}
@@ -243,6 +244,30 @@ private static bool CompareResult(int cmp, ComparisonOperator op)
243244
};
244245
}
245246

247+
private TemplateValue EvaluateIndexAccess(IndexAccessExpression expr, TemplateContext context)
248+
{
249+
var obj = Evaluate(expr.Target, context);
250+
var index = Evaluate(expr.Index, context);
251+
252+
return (obj, index) switch
253+
{
254+
(ObjectValue objVal, StringValue strKey) => objVal[strKey.Value],
255+
(ObjectValue objVal, NumberValue numKey) => objVal[numKey.Value.ToString("G", CultureInfo.InvariantCulture)],
256+
(ArrayValue arrVal, NumberValue numIdx) => EvaluateArrayIndex(arrVal, numIdx),
257+
_ => NullValue.Instance
258+
};
259+
}
260+
261+
private static TemplateValue EvaluateArrayIndex(ArrayValue array, NumberValue index)
262+
{
263+
var idx = (int)Math.Truncate(index.Value);
264+
if (idx < 0 || idx >= array.Count || idx > ExpressionEvaluator.MaxArrayIndex)
265+
{
266+
return NullValue.Instance;
267+
}
268+
return array[idx];
269+
}
270+
246271
private BoolValue EvaluateNot(NotExpression expr, TemplateContext context)
247272
{
248273
var operand = Evaluate(expr.Operand, context);

src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ namespace FlexRender.TemplateEngine;
77
/// <summary>
88
/// Pratt parser for inline expressions within <c>{{...}}</c> blocks.
99
/// Supports arithmetic operators, comparison operators, logical NOT, logical OR (<c>||</c>),
10-
/// logical AND (<c>&amp;&amp;</c>), null coalesce, filter pipes, and parenthesized grouping.
10+
/// logical AND (<c>&amp;&amp;</c>), null coalesce, filter pipes, computed index access (<c>[]</c>),
11+
/// and parenthesized grouping.
1112
/// </summary>
1213
/// <remarks>
1314
/// <para>Operator precedence (lowest to highest):</para>
@@ -20,6 +21,7 @@ namespace FlexRender.TemplateEngine;
2021
/// <item><c>+</c>, <c>-</c> (add, subtract)</item>
2122
/// <item><c>*</c>, <c>/</c> (multiply, divide)</item>
2223
/// <item>Unary <c>-</c> (negation), <c>!</c> (logical NOT)</item>
24+
/// <item><c>[]</c> (computed index/key access), <c>.</c> (member access after index)</item>
2325
/// <item><c>()</c> (grouping)</item>
2426
/// </list>
2527
/// <para>
@@ -94,6 +96,17 @@ public static bool NeedsFullParsing(string content)
9496
// and rely on the regex for simple path detection below
9597
}
9698

99+
// Check for computed key access: [non-digit] requires full parsing
100+
// Simple numeric indices like items[0] do not need full parsing
101+
if (content.Contains('['))
102+
{
103+
var bracketIdx = content.IndexOf('[');
104+
if (bracketIdx + 1 < content.Length && !char.IsDigit(content[bracketIdx + 1]))
105+
{
106+
return true;
107+
}
108+
}
109+
97110
// Check if it's a simple path (no operators)
98111
// Minus in paths is not an operator: "my-var" is a valid path
99112
if (content.Contains('-'))
@@ -326,6 +339,8 @@ private InlineExpression ParseInfix(InlineExpression left, Precedence precedence
326339
'-' => ParseArithmetic(left, ArithmeticOperator.Subtract),
327340
'*' => ParseArithmetic(left, ArithmeticOperator.Multiply),
328341
'/' => ParseArithmetic(left, ArithmeticOperator.Divide),
342+
'[' => ParseIndexAccess(left),
343+
'.' => ParseMemberAccess(left),
329344
_ => throw new TemplateEngineException(
330345
$"Unexpected operator '{c}'",
331346
position: _pos,
@@ -641,7 +656,7 @@ private InlineExpression ParsePath()
641656
{
642657
var c = _input[_pos];
643658

644-
if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '[' || c == ']' || c == '@')
659+
if (char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '@')
645660
{
646661
_pos++;
647662
continue;
@@ -697,10 +712,60 @@ private InlineExpression ParsePath()
697712
'>' => (Precedence.Comparison, false),
698713
'+' or '-' => (Precedence.Additive, false),
699714
'*' or '/' => (Precedence.Multiplicative, false),
715+
'[' => (Precedence.Postfix, false),
716+
'.' when _pos > 0 => (Precedence.Postfix, false),
700717
_ => (Precedence.None, false)
701718
};
702719
}
703720

721+
private IndexAccessExpression ParseIndexAccess(InlineExpression left)
722+
{
723+
_pos++; // skip [
724+
var index = ParseExpression(Precedence.None);
725+
SkipWhitespace();
726+
if (_pos >= _input.Length || _input[_pos] != ']')
727+
{
728+
throw new TemplateEngineException(
729+
"Missing closing bracket ']'",
730+
position: _pos,
731+
expression: _input);
732+
}
733+
_pos++; // skip ]
734+
return new IndexAccessExpression(left, index);
735+
}
736+
737+
private IndexAccessExpression ParseMemberAccess(InlineExpression left)
738+
{
739+
_pos++; // skip .
740+
var start = _pos;
741+
while (_pos < _input.Length)
742+
{
743+
var c = _input[_pos];
744+
if (char.IsLetterOrDigit(c) || c == '_' || c == '@')
745+
{
746+
_pos++;
747+
continue;
748+
}
749+
// Allow hyphens in property names (same rules as ParsePath)
750+
if (c == '-' && _pos + 1 < _input.Length && !char.IsWhiteSpace(_input[_pos + 1])
751+
&& _pos > start && !char.IsWhiteSpace(_input[_pos - 1]))
752+
{
753+
_pos++;
754+
continue;
755+
}
756+
break;
757+
}
758+
if (_pos == start)
759+
{
760+
throw new TemplateEngineException(
761+
"Expected property name after '.'",
762+
position: _pos,
763+
expression: _input);
764+
}
765+
var propName = _input[start.._pos];
766+
return new IndexAccessExpression(left, new StringLiteral(propName));
767+
}
768+
704769
private bool IsDoubleChar(char c)
705770
{
706771
return _pos + 1 < _input.Length && _input[_pos] == c && _input[_pos + 1] == c;
@@ -724,6 +789,7 @@ private enum Precedence
724789
Comparison = 5,
725790
Additive = 6,
726791
Multiplicative = 7,
727-
Unary = 8
792+
Unary = 8,
793+
Postfix = 9
728794
}
729795
}

0 commit comments

Comments
 (0)