Skip to content

Commit 119d9eb

Browse files
committed
New expressions
1 parent 006b4b8 commit 119d9eb

9 files changed

Lines changed: 2220 additions & 22 deletions

File tree

docs/wiki/Template-Expressions.md

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Variables can be used in most string properties: `content`, `data`, `src`, `colo
3131

3232
## Inline Expressions
3333

34-
FlexRender supports inline expressions within `{{ }}` delimiters. Expressions extend simple variable substitution with arithmetic, null coalescing, and filters.
34+
FlexRender supports inline expressions within `{{ }}` delimiters. Expressions extend simple variable substitution with arithmetic, comparison operators, logical NOT, null coalescing, and filters.
3535

3636
### Arithmetic
3737

@@ -57,6 +57,52 @@ Arithmetic operators work on numeric values:
5757

5858
Both operands must be numeric (NumberValue). Division by zero returns a null value.
5959

60+
### Comparison Operators
61+
62+
Comparison operators return boolean values and are primarily used in `{{#if}}` conditions:
63+
64+
| Operator | Description | Example |
65+
|----------|-------------|---------|
66+
| `==` | Equal | `{{#if status == 'paid'}}...{{/if}}` |
67+
| `!=` | Not equal | `{{#if status != 'cancelled'}}...{{/if}}` |
68+
| `<` | Less than | `{{#if stock < 5}}...{{/if}}` |
69+
| `>` | Greater than | `{{#if total > 1000}}...{{/if}}` |
70+
| `<=` | Less than or equal | `{{#if quantity <= 10}}...{{/if}}` |
71+
| `>=` | Greater than or equal | `{{#if rating >= 4}}...{{/if}}` |
72+
73+
```yaml
74+
# Show status based on comparison
75+
- type: text
76+
content: "{{#if total > 1000}}Free shipping!{{else}}Shipping: 10${{/if}}"
77+
78+
# Check string equality
79+
- type: text
80+
content: "{{#if status == 'paid'}}Payment received{{else}}Awaiting payment{{/if}}"
81+
```
82+
83+
Comparison rules:
84+
- **Numbers**: compared by value (`100 == 100.0` is true)
85+
- **Strings**: compared using ordinal (case-sensitive) comparison
86+
- **Booleans**: only `==` and `!=` supported; ordered comparisons return false
87+
- **Null**: `null == null` is true; `null != <anything>` is true; ordered comparisons with null return false
88+
- **Mixed types** (e.g., string vs number): `==` is false, `!=` is true, ordered comparisons return false
89+
90+
### Logical NOT
91+
92+
The `!` operator inverts the truthiness of a value:
93+
94+
```yaml
95+
# Show when NOT active
96+
- type: text
97+
content: "{{#if !active}}Account is inactive{{/if}}"
98+
99+
# NOT with comparison
100+
- type: text
101+
content: "{{#if !(total > 1000)}}Standard shipping{{/if}}"
102+
```
103+
104+
The `!` operator evaluates the operand for [truthiness](#conditional-blocks) and returns the opposite boolean value.
105+
60106
### Null Coalescing
61107

62108
The `??` operator provides a fallback when the left side is null or missing:
@@ -127,11 +173,12 @@ Operators are evaluated in this order (highest to lowest):
127173

128174
| Precedence | Operators |
129175
|------------|-----------|
130-
| 1 (highest) | Unary minus (`-x`) |
176+
| 1 (highest) | Logical NOT (`!x`), Unary minus (`-x`) |
131177
| 2 | Multiplication, Division (`*`, `/`) |
132178
| 3 | Addition, Subtraction (`+`, `-`) |
133-
| 4 | Null coalescing (`??`) |
134-
| 5 (lowest) | Filter pipe (`\|`) |
179+
| 4 | Comparison (`==`, `!=`, `<`, `>`, `<=`, `>=`) |
180+
| 5 | Null coalescing (`??`) |
181+
| 6 (lowest) | Filter pipe (`\|`) |
135182

136183
### Expression Limits
137184

@@ -173,6 +220,119 @@ public interface ITemplateFilter
173220
}
174221
```
175222

223+
---
224+
225+
## Text Blocks
226+
227+
Text blocks provide control flow inside `content` strings. They are processed by the template engine at render time.
228+
229+
> **Note:** Text blocks (`{{#if}}`, `{{#each}}`) work inside text `content` values. For element-level conditions and loops, use the `if` and `for-each` element properties instead.
230+
231+
### Conditional Blocks
232+
233+
```
234+
{{#if condition}}...{{/if}}
235+
{{#if condition}}...{{else}}...{{/if}}
236+
```
237+
238+
The condition is evaluated for truthiness:
239+
240+
| Value | Truthy? |
241+
|-------|---------|
242+
| Non-empty string | Yes |
243+
| Non-zero number | Yes |
244+
| `true` | Yes |
245+
| Non-empty array | Yes |
246+
| Non-empty object | Yes |
247+
| `null` / missing key | No |
248+
| Empty string `""` | No |
249+
| `0` | No |
250+
| `false` | No |
251+
| Empty array `[]` | No |
252+
253+
Conditions support full expressions including comparison operators, logical NOT, null coalescing, arithmetic, and filters:
254+
255+
```yaml
256+
- type: text
257+
content: "{{#if name}}Hello {{name}}{{else}}Hello guest{{/if}}"
258+
259+
- type: text
260+
content: "{{#if name ?? nickname}}Hi {{name ?? nickname}}{{/if}}"
261+
262+
- type: text
263+
content: "{{#if count}}{{count}} items{{else}}No items{{/if}}"
264+
265+
# Comparison in conditions
266+
- type: text
267+
content: "{{#if count > 0}}{{count}} items{{else}}No items{{/if}}"
268+
269+
- type: text
270+
content: "{{#if status == 'active'}}Online{{else}}Offline{{/if}}"
271+
272+
# Logical NOT in conditions
273+
- type: text
274+
content: "{{#if !disabled}}Feature enabled{{/if}}"
275+
```
276+
277+
### Loop Blocks
278+
279+
```
280+
{{#each arrayPath}}...{{/each}}
281+
```
282+
283+
Iterates over an array. Inside the loop body, the current item's properties are accessible directly. Loop variables:
284+
285+
| Variable | Type | Description |
286+
|----------|------|-------------|
287+
| `@index` | number | 0-based iteration index |
288+
| `@first` | bool | `true` for the first item |
289+
| `@last` | bool | `true` for the last item |
290+
291+
```yaml
292+
- type: text
293+
content: "{{#each items}}{{name}}{{#if @last}}.{{else}}, {{/if}}{{/each}}"
294+
295+
# Output with items=[{name:"A"},{name:"B"},{name:"C"}]: "A, B, C."
296+
```
297+
298+
### Nesting
299+
300+
Text blocks can be nested. The maximum nesting depth is controlled by `ResourceLimits.MaxTemplateNestingDepth` (default: 100).
301+
302+
```yaml
303+
- type: text
304+
content: "{{#each groups}}[{{#each items}}{{val}}{{/each}}]{{/each}}"
305+
306+
- type: text
307+
content: "{{#each users}}{{#if active}}{{name}} {{/if}}{{/each}}"
308+
```
309+
310+
### String Literals and Escape Sequences
311+
312+
String literals in expressions support both single and double quotes:
313+
314+
```
315+
{{name ?? "default"}}
316+
{{name ?? 'default'}}
317+
```
318+
319+
Escape sequences are supported inside string literals:
320+
321+
| Escape | Result |
322+
|--------|--------|
323+
| `\\` | `\` |
324+
| `\"` | `"` |
325+
| `\'` | `'` |
326+
| `\n` | newline |
327+
| `\t` | tab |
328+
329+
```yaml
330+
- type: text
331+
content: "{{greeting ?? 'it\\'s a default'}}"
332+
```
333+
334+
---
335+
176336
### Data Structure
177337
178338
Data is passed as `ObjectValue`:

src/FlexRender.Core/TemplateEngine/InlineExpression.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,42 @@ public sealed record FilterExpression(InlineExpression Input, string FilterName,
7272
/// </summary>
7373
/// <param name="Operand">The expression to negate.</param>
7474
public sealed record NegateExpression(InlineExpression Operand) : InlineExpression;
75+
76+
/// <summary>
77+
/// Comparison operators supported in inline expressions.
78+
/// </summary>
79+
public enum ComparisonOperator
80+
{
81+
/// <summary>Equality operator (==).</summary>
82+
Equal,
83+
84+
/// <summary>Inequality operator (!=).</summary>
85+
NotEqual,
86+
87+
/// <summary>Less than operator (&lt;).</summary>
88+
LessThan,
89+
90+
/// <summary>Greater than operator (&gt;).</summary>
91+
GreaterThan,
92+
93+
/// <summary>Less than or equal operator (&lt;=).</summary>
94+
LessThanOrEqual,
95+
96+
/// <summary>Greater than or equal operator (&gt;=).</summary>
97+
GreaterThanOrEqual
98+
}
99+
100+
/// <summary>
101+
/// A binary comparison expression (e.g., <c>price &gt; 100</c>, <c>status == "paid"</c>).
102+
/// </summary>
103+
/// <param name="Left">The left operand.</param>
104+
/// <param name="Op">The comparison operator.</param>
105+
/// <param name="Right">The right operand.</param>
106+
public sealed record ComparisonExpression(InlineExpression Left, ComparisonOperator Op, InlineExpression Right) : InlineExpression;
107+
108+
/// <summary>
109+
/// A logical NOT expression (e.g., <c>!isActive</c>).
110+
/// Returns true when the operand is falsy, false when truthy.
111+
/// </summary>
112+
/// <param name="Operand">The expression to negate logically.</param>
113+
public sealed record NotExpression(InlineExpression Operand) : InlineExpression;

src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ namespace FlexRender.TemplateEngine;
1313
/// <item>String + number (mixed types) produces <see cref="NullValue"/> (no implicit coercion).</item>
1414
/// <item>Division by zero produces <see cref="NullValue"/> (no exception).</item>
1515
/// <item>Null coalesce: returns right if left is <see cref="NullValue"/>.</item>
16+
/// <item>Comparison operators (<c>==</c>, <c>!=</c>, <c>&lt;</c>, <c>&gt;</c>, <c>&lt;=</c>, <c>&gt;=</c>)
17+
/// produce <see cref="BoolValue"/>. Null compared to null is equal; null compared to non-null
18+
/// yields false for all ordered comparisons (SQL NULL semantics).</item>
19+
/// <item>Logical NOT (<c>!</c>) returns <see cref="BoolValue"/> based on the truthiness of the operand.</item>
1620
/// </list>
1721
/// </remarks>
1822
public sealed class InlineExpressionEvaluator
@@ -73,9 +77,11 @@ public TemplateValue Evaluate(InlineExpression expression, TemplateContext conte
7377
NumberLiteral num => new NumberValue(num.Value),
7478
StringLiteral str => new StringValue(str.Value),
7579
ArithmeticExpression arith => EvaluateArithmetic(arith, context),
80+
ComparisonExpression comp => EvaluateComparison(comp, context),
7681
CoalesceExpression coal => EvaluateCoalesce(coal, context),
7782
FilterExpression filter => EvaluateFilter(filter, context),
7883
NegateExpression neg => EvaluateNegate(neg, context),
84+
NotExpression not => EvaluateNot(not, context),
7985
_ => NullValue.Instance
8086
};
8187
}
@@ -140,4 +146,70 @@ private TemplateValue EvaluateNegate(NegateExpression expr, TemplateContext cont
140146

141147
return NullValue.Instance;
142148
}
149+
150+
private BoolValue EvaluateComparison(ComparisonExpression expr, TemplateContext context)
151+
{
152+
var left = Evaluate(expr.Left, context);
153+
var right = Evaluate(expr.Right, context);
154+
155+
// Both null
156+
if (left is NullValue && right is NullValue)
157+
{
158+
return new BoolValue(expr.Op is ComparisonOperator.Equal or ComparisonOperator.LessThanOrEqual or ComparisonOperator.GreaterThanOrEqual);
159+
}
160+
161+
// One null
162+
if (left is NullValue || right is NullValue)
163+
{
164+
return new BoolValue(expr.Op == ComparisonOperator.NotEqual);
165+
}
166+
167+
// Number comparison
168+
if (left is NumberValue leftNum && right is NumberValue rightNum)
169+
{
170+
var cmp = leftNum.Value.CompareTo(rightNum.Value);
171+
return new BoolValue(CompareResult(cmp, expr.Op));
172+
}
173+
174+
// String comparison
175+
if (left is StringValue leftStr && right is StringValue rightStr)
176+
{
177+
var cmp = string.Compare(leftStr.Value, rightStr.Value, StringComparison.Ordinal);
178+
return new BoolValue(CompareResult(cmp, expr.Op));
179+
}
180+
181+
// Bool comparison (only == and !=)
182+
if (left is BoolValue leftBool && right is BoolValue rightBool)
183+
{
184+
return expr.Op switch
185+
{
186+
ComparisonOperator.Equal => new BoolValue(leftBool.Value == rightBool.Value),
187+
ComparisonOperator.NotEqual => new BoolValue(leftBool.Value != rightBool.Value),
188+
_ => new BoolValue(false) // ordered comparison on bools is always false
189+
};
190+
}
191+
192+
// Mixed types: == is false, != is true, ordered is false
193+
return new BoolValue(expr.Op == ComparisonOperator.NotEqual);
194+
}
195+
196+
private static bool CompareResult(int cmp, ComparisonOperator op)
197+
{
198+
return op switch
199+
{
200+
ComparisonOperator.Equal => cmp == 0,
201+
ComparisonOperator.NotEqual => cmp != 0,
202+
ComparisonOperator.LessThan => cmp < 0,
203+
ComparisonOperator.GreaterThan => cmp > 0,
204+
ComparisonOperator.LessThanOrEqual => cmp <= 0,
205+
ComparisonOperator.GreaterThanOrEqual => cmp >= 0,
206+
_ => false
207+
};
208+
}
209+
210+
private BoolValue EvaluateNot(NotExpression expr, TemplateContext context)
211+
{
212+
var operand = Evaluate(expr.Operand, context);
213+
return new BoolValue(!ExpressionEvaluator.IsTruthy(operand));
214+
}
143215
}

0 commit comments

Comments
 (0)