diff --git a/context7.json b/context7.json
deleted file mode 100644
index 99434e2..0000000
--- a/context7.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "url": "https://context7.com/robonet/flexrender",
- "public_key": "pk_UlhP3JRdbqRbh7DmCQaRC"
-}
diff --git a/docs/wiki/Template-Expressions.md b/docs/wiki/Template-Expressions.md
index 4bfb0a3..b7ec3e1 100644
--- a/docs/wiki/Template-Expressions.md
+++ b/docs/wiki/Template-Expressions.md
@@ -168,13 +168,22 @@ The `??` operator provides a fallback when the left side is null or missing:
### Filters
-Filters transform values using the pipe (`|`) syntax. Filters can take an optional argument after a colon:
+Filters transform values using the pipe (`|`) syntax. Filters support a positional argument, named parameters (`key:value`), and boolean flags:
```yaml
{{value | filterName}}
{{value | filterName:argument}}
+{{value | filterName:positional key1:value1 key2:'string' flag}}
+{{value | filterName key1:value1 flag}}
```
+**Three modes:**
+- Positional only: `{{value | truncate:30}}`
+- Named only: `{{value | truncate length:30 suffix:'…'}}`
+- Mixed: `{{value | truncate:30 suffix:'…' fromEnd}}`
+
+Named parameters use `key:value` syntax. Boolean flags are just the key name without a value.
+
#### Built-in Filters
All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on `FlexRenderBuilder` to disable them if needed.
@@ -186,7 +195,7 @@ All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on
| `upper` | -- | Convert string to uppercase | `{{name \| upper}}` -> `"JOHN"` |
| `lower` | -- | Convert string to lowercase | `{{name \| lower}}` -> `"john"` |
| `trim` | -- | Remove leading/trailing whitespace | `{{input \| trim}}` |
-| `truncate` | max length (default: 50) | Truncate string with "..." suffix | `{{desc \| truncate:20}}` |
+| `truncate` | `length` (positional, default: 50), `suffix` (default: "..."), `fromEnd` (flag) | Truncate string with configurable suffix and direction | `{{desc \| truncate:20}}`, `{{path \| truncate:20 fromEnd suffix:'…'}}` |
| `format` | format string | Format number or date with .NET format string | `{{date \| format:"dd.MM.yyyy"}}` |
| `currencySymbol` | -- | Convert ISO 4217 currency code (alphabetic or numeric) to symbol | `{{currency \| currencySymbol}}` -> `"$"`, `{{840 \| currencySymbol}}` -> `"$"` |
@@ -214,6 +223,18 @@ All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on
# Truncated description
- type: text
content: "{{product.description | truncate:50}}"
+
+# Truncated description with custom suffix
+- type: text
+ content: "{{product.description | truncate:30 suffix:'…'}}"
+
+# Keep last 20 chars of file path
+- type: text
+ content: "{{file.path | truncate:20 fromEnd}}"
+
+# Truncate with all named parameters
+- type: text
+ content: "{{text | truncate length:25 suffix:'...' fromEnd}}"
```
### Expression Precedence
@@ -267,10 +288,15 @@ Custom filters implement `ITemplateFilter`:
public interface ITemplateFilter
{
string Name { get; }
- TemplateValue Apply(TemplateValue input, TemplateValue? argument);
+ TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture);
}
```
+The `FilterArguments` class provides:
+- `Positional` -- the first (unnamed) argument, or null
+- `GetNamed(name, defaultValue)` -- get a named parameter by key
+- `HasFlag(name)` -- check if a boolean flag is present
+
---
## Text Blocks
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/CurrencyFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/CurrencyFilter.cs
index 61cb3f2..32da0f7 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/CurrencyFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/CurrencyFilter.cs
@@ -16,7 +16,7 @@ public sealed class CurrencyFilter : ITemplateFilter
public string Name => "currency";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is NumberValue num)
{
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/CurrencySymbolFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/CurrencySymbolFilter.cs
index 03e327a..d4ea1a4 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/CurrencySymbolFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/CurrencySymbolFilter.cs
@@ -172,7 +172,7 @@ public sealed class CurrencySymbolFilter : ITemplateFilter
public string Name => "currencySymbol";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/FormatFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/FormatFilter.cs
index 67624c9..8260b3d 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/FormatFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/FormatFilter.cs
@@ -29,9 +29,9 @@ public sealed class FormatFilter : ITemplateFilter
public string Name => "format";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
- if (argument is not StringValue formatStr || string.IsNullOrEmpty(formatStr.Value))
+ if (arguments.Positional is not StringValue formatStr || string.IsNullOrEmpty(formatStr.Value))
{
return input;
}
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/LowerFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/LowerFilter.cs
index 9b8ae1c..919dfbb 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/LowerFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/LowerFilter.cs
@@ -15,7 +15,7 @@ public sealed class LowerFilter : ITemplateFilter
public string Name => "lower";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/NumberFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/NumberFilter.cs
index b7a4f28..b96e1f9 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/NumberFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/NumberFilter.cs
@@ -21,7 +21,7 @@ public sealed class NumberFilter : ITemplateFilter
public string Name => "number";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is not NumberValue num)
{
@@ -29,7 +29,7 @@ public TemplateValue Apply(TemplateValue input, TemplateValue? argument, Culture
}
var decimals = 0;
- if (argument is StringValue argStr &&
+ if (arguments.Positional is StringValue argStr &&
int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
decimals = Math.Clamp(parsed, 0, MaxDecimalPlaces);
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/TrimFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/TrimFilter.cs
index 6e3a9ae..26ed4a0 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/TrimFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/TrimFilter.cs
@@ -15,7 +15,7 @@ public sealed class TrimFilter : ITemplateFilter
public string Name => "trim";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs
index 85c7d85..79e9e6f 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs
@@ -3,11 +3,25 @@
namespace FlexRender.TemplateEngine.Filters;
///
-/// Truncates a string to a maximum length, appending "..." if truncated.
-/// Not affected by culture settings.
+/// Truncates a string to a maximum length with a configurable suffix.
+/// Supports truncation from start or end of string.
///
+///
+/// Parameters:
+///
+/// - length (positional, default 50): Maximum length of the result including suffix.
+/// - suffix (named, default "..."): The suffix/prefix to add when truncating.
+/// - fromEnd (flag): When present, keeps the end of the string and adds suffix as prefix.
+///
+///
+/// Non-string input is converted: and
+/// become strings; becomes empty string;
+/// and are returned unchanged.
+///
+///
///
-/// {{desc | truncate:10}} with desc="Hello World!" produces Hello W....
+/// {{desc | truncate:10}} produces Hello W....
+/// {{path | truncate:20 fromEnd suffix:'...'}} produces ...ts/SkiaLayout/src.
///
public sealed class TruncateFilter : ITemplateFilter
{
@@ -17,38 +31,83 @@ public sealed class TruncateFilter : ITemplateFilter
private const int MaxLength = 10000;
///
- /// Suffix appended when a string is truncated.
+ /// Maximum allowed suffix length to prevent misuse.
///
- private const string Ellipsis = "...";
+ private const int MaxSuffixLength = 100;
+
+ ///
+ /// Default suffix appended or prepended when a string is truncated.
+ ///
+ private const string DefaultSuffix = "...";
///
public string Name => "truncate";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
- if (input is not StringValue str)
+ // Convert non-string input to string
+ var text = input switch
+ {
+ StringValue sv => sv.Value,
+ NumberValue nv => nv.Value.ToString("G", culture),
+ BoolValue bv => bv.Value ? "true" : "false",
+ NullValue => "",
+ _ => (string?)null
+ };
+
+ if (text is null)
{
- return input;
+ return input; // ArrayValue, ObjectValue — return as-is
}
- var maxLen = 50; // default
- if (argument is StringValue argStr &&
- int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
+ // Resolve length: named "length" overrides positional
+ var maxLen = 50;
+ var lengthSource = arguments.GetNamed("length", NullValue.Instance);
+ if (lengthSource is StringValue lenStr &&
+ int.TryParse(lenStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lenParsed))
+ {
+ maxLen = Math.Clamp(lenParsed, 0, MaxLength);
+ }
+ else if (arguments.Positional is StringValue argStr &&
+ int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
maxLen = Math.Clamp(parsed, 0, MaxLength);
}
- if (str.Value.Length <= maxLen)
+ if (text.Length <= maxLen)
{
- return str;
+ return new StringValue(text);
}
- if (maxLen <= Ellipsis.Length)
+ // Resolve suffix
+ var suffix = DefaultSuffix;
+ var suffixValue = arguments.GetNamed("suffix", NullValue.Instance);
+ if (suffixValue is StringValue suffixStr)
+ {
+ suffix = suffixStr.Value;
+ if (suffix.Length > MaxSuffixLength)
+ {
+ suffix = suffix[..MaxSuffixLength];
+ }
+ }
+
+ // Resolve direction
+ var fromEnd = arguments.HasFlag("fromEnd");
+
+ // Edge case: length <= suffix length
+ if (maxLen <= suffix.Length)
+ {
+ return new StringValue(suffix[..maxLen]);
+ }
+
+ var contentLen = maxLen - suffix.Length;
+
+ if (fromEnd)
{
- return new StringValue(Ellipsis[..maxLen]);
+ return new StringValue(string.Concat(suffix, text.AsSpan(text.Length - contentLen, contentLen)));
}
- return new StringValue(string.Concat(str.Value.AsSpan(0, maxLen - Ellipsis.Length), Ellipsis));
+ return new StringValue(string.Concat(text.AsSpan(0, contentLen), suffix));
}
}
diff --git a/src/FlexRender.Core/TemplateEngine/Filters/UpperFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/UpperFilter.cs
index 67eee38..263d346 100644
--- a/src/FlexRender.Core/TemplateEngine/Filters/UpperFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/Filters/UpperFilter.cs
@@ -15,7 +15,7 @@ public sealed class UpperFilter : ITemplateFilter
public string Name => "upper";
///
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
diff --git a/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs
index 76649c9..db2fa9e 100644
--- a/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs
+++ b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs
@@ -1,7 +1,74 @@
+using System.Collections.ObjectModel;
using System.Globalization;
namespace FlexRender.TemplateEngine;
+///
+/// Holds parsed filter arguments — an optional positional argument plus named key:value pairs and flags.
+///
+public sealed class FilterArguments
+{
+ private static readonly IReadOnlyDictionary EmptyNamed =
+ new ReadOnlyDictionary(new Dictionary());
+
+ ///
+ /// Empty arguments instance. Used when a filter has no arguments.
+ ///
+ public static readonly FilterArguments Empty = new(null, EmptyNamed);
+
+ ///
+ /// Gets a shared empty named-arguments dictionary for reuse when no named arguments are present.
+ ///
+ internal static IReadOnlyDictionary EmptyNamedDictionary => EmptyNamed;
+
+ private readonly IReadOnlyDictionary _named;
+
+ ///
+ /// Initializes a new instance with positional and named arguments.
+ ///
+ /// The positional argument, or null if absent.
+ /// Named arguments. A null value indicates a boolean flag.
+ public FilterArguments(TemplateValue? positional, IReadOnlyDictionary named)
+ {
+ ArgumentNullException.ThrowIfNull(named);
+ Positional = positional;
+ _named = named;
+ }
+
+ ///
+ /// Gets the positional (first, unnamed) argument, or null if not provided.
+ ///
+ public TemplateValue? Positional { get; }
+
+ ///
+ /// Gets a named argument by key, returning if absent.
+ ///
+ /// The name of the argument to retrieve.
+ /// The value to return if the named argument is not found.
+ /// The named argument value if present and non-null; otherwise, .
+ public TemplateValue GetNamed(string name, TemplateValue defaultValue)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ if (_named.TryGetValue(name, out var value) && value is not null)
+ {
+ return value;
+ }
+
+ return defaultValue;
+ }
+
+ ///
+ /// Returns true if the given flag is present (a named key with no value).
+ ///
+ /// The flag name to check.
+ /// True if the flag is present (key exists with null value); otherwise, false.
+ public bool HasFlag(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ return _named.TryGetValue(name, out var value) && value is null;
+ }
+}
+
///
/// Interface for template filters that transform values in filter pipe expressions.
///
@@ -29,8 +96,8 @@ public interface ITemplateFilter
/// Applies the filter to the input value.
///
/// The value to transform.
- /// An optional argument from the filter syntax (e.g., truncate:30 passes "30").
+ /// The filter arguments (positional, named, and flags).
/// The culture to use for formatting operations.
/// The transformed value.
- TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture);
+ TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture);
}
diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs
index 27895e9..0385bc8 100644
--- a/src/FlexRender.Core/TemplateEngine/InlineExpression.cs
+++ b/src/FlexRender.Core/TemplateEngine/InlineExpression.cs
@@ -70,13 +70,27 @@ public sealed record ArithmeticExpression(InlineExpression Left, ArithmeticOpera
public sealed record CoalesceExpression(InlineExpression Left, InlineExpression Right) : InlineExpression;
///
-/// A filter pipe expression (e.g., price | currency, name | truncate:30).
+/// A named argument for a filter (e.g., suffix:'...'). A null
+/// indicates a boolean flag (e.g., fromEnd).
+///
+/// The argument name.
+/// The argument value, or null for boolean flags.
+public sealed record FilterNamedArgument(string Name, string? Value);
+
+///
+/// A filter pipe expression (e.g., price | currency, name | truncate:30 suffix:'...' fromEnd).
/// Applies a named filter to the input expression.
///
/// The expression whose result is passed to the filter.
/// The name of the filter to apply.
-/// An optional string argument to the filter (after the colon).
-public sealed record FilterExpression(InlineExpression Input, string FilterName, string? Argument) : InlineExpression;
+/// An optional positional string argument (after the colon).
+/// Optional named arguments and flags.
+public sealed record FilterExpression(
+ InlineExpression Input,
+ string FilterName,
+ string? Argument,
+ IReadOnlyList? NamedArguments = null
+) : InlineExpression;
///
/// A unary negation expression (e.g., -price).
diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs
index f2fece7..ef4ea6d 100644
--- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs
+++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs
@@ -146,11 +146,29 @@ private TemplateValue EvaluateFilter(FilterExpression expr, TemplateContext cont
var filter = _filterRegistry.Get(expr.FilterName)
?? throw new TemplateEngineException($"Unknown filter '{expr.FilterName}'");
- TemplateValue? argument = expr.Argument is not null
+ var positional = expr.Argument is not null
? new StringValue(expr.Argument)
: null;
- return filter.Apply(input, argument, _culture);
+ FilterArguments arguments;
+ if (expr.NamedArguments is { Count: > 0 })
+ {
+ var named = new Dictionary(expr.NamedArguments.Count, StringComparer.Ordinal);
+ foreach (var arg in expr.NamedArguments)
+ {
+ named[arg.Name] = arg.Value is not null ? new StringValue(arg.Value) : null;
+ }
+
+ arguments = new FilterArguments(positional, named);
+ }
+ else
+ {
+ arguments = positional is not null
+ ? new FilterArguments(positional, FilterArguments.EmptyNamedDictionary)
+ : FilterArguments.Empty;
+ }
+
+ return filter.Apply(input, arguments, _culture);
}
private TemplateValue EvaluateNegate(NegateExpression expr, TemplateContext context)
diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs
index 6f24c62..63a5345 100644
--- a/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs
+++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionParser.cs
@@ -397,7 +397,45 @@ private InlineExpression ParseFilter(InlineExpression input)
argument = ReadFilterArgument();
}
- var filterExpr = new FilterExpression(input, filterName, argument);
+ // Parse named arguments and flags after positional argument
+ List? namedArgs = null;
+ SkipWhitespace();
+ while (_pos < _input.Length && _input[_pos] != '|' && _input[_pos] != '}')
+ {
+ // Must start with a letter (identifier for named param or flag)
+ if (!char.IsLetter(_input[_pos]))
+ {
+ break;
+ }
+
+ // Read the identifier name
+ var nameStart = _pos;
+ while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
+ {
+ _pos++;
+ }
+
+ var paramName = _input[nameStart.._pos];
+
+ if (_pos < _input.Length && _input[_pos] == ':')
+ {
+ // Named parameter with value: key:value
+ _pos++; // skip :
+ var paramValue = ReadFilterArgument();
+ namedArgs ??= [];
+ namedArgs.Add(new FilterNamedArgument(paramName, paramValue));
+ }
+ else
+ {
+ // Boolean flag: just a name
+ namedArgs ??= [];
+ namedArgs.Add(new FilterNamedArgument(paramName, null));
+ }
+
+ SkipWhitespace();
+ }
+
+ var filterExpr = new FilterExpression(input, filterName, argument, namedArgs);
// Allow chaining: {{name | trim | upper}}
SkipWhitespace();
diff --git a/tests/FlexRender.Tests/Configuration/FlexRenderBuilderTests.cs b/tests/FlexRender.Tests/Configuration/FlexRenderBuilderTests.cs
index 03ca7fb..0cd3915 100644
--- a/tests/FlexRender.Tests/Configuration/FlexRenderBuilderTests.cs
+++ b/tests/FlexRender.Tests/Configuration/FlexRenderBuilderTests.cs
@@ -779,5 +779,5 @@ public async Task Render_ToStream_WritesValidPng()
public StubFilter(string name) => Name = name;
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture) => input;
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture) => input;
}
diff --git a/tests/FlexRender.Tests/Expressions/FilterArgumentsTests.cs b/tests/FlexRender.Tests/Expressions/FilterArgumentsTests.cs
new file mode 100644
index 0000000..952f74b
--- /dev/null
+++ b/tests/FlexRender.Tests/Expressions/FilterArgumentsTests.cs
@@ -0,0 +1,96 @@
+using FlexRender.TemplateEngine;
+using Xunit;
+
+namespace FlexRender.Tests.Expressions;
+
+///
+/// Tests for the class that holds parsed filter arguments.
+///
+public sealed class FilterArgumentsTests
+{
+ [Fact]
+ public void Positional_ReturnsPositionalArgument()
+ {
+ var args = new FilterArguments(new StringValue("30"), new Dictionary());
+
+ Assert.NotNull(args.Positional);
+ var str = Assert.IsType(args.Positional);
+ Assert.Equal("30", str.Value);
+ }
+
+ [Fact]
+ public void Positional_WhenNull_ReturnsNull()
+ {
+ var args = new FilterArguments(null, new Dictionary());
+
+ Assert.Null(args.Positional);
+ }
+
+ [Fact]
+ public void GetNamed_ExistingKey_ReturnsValue()
+ {
+ var named = new Dictionary
+ {
+ ["suffix"] = new StringValue("px")
+ };
+ var args = new FilterArguments(null, named);
+
+ var result = args.GetNamed("suffix", NullValue.Instance);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("px", str.Value);
+ }
+
+ [Fact]
+ public void GetNamed_MissingKey_ReturnsDefault()
+ {
+ var args = new FilterArguments(null, new Dictionary());
+
+ var result = args.GetNamed("missing", new StringValue("default"));
+
+ var str = Assert.IsType(result);
+ Assert.Equal("default", str.Value);
+ }
+
+ [Fact]
+ public void HasFlag_PresentFlag_ReturnsTrue()
+ {
+ var named = new Dictionary
+ {
+ ["fromEnd"] = null
+ };
+ var args = new FilterArguments(null, named);
+
+ Assert.True(args.HasFlag("fromEnd"));
+ }
+
+ [Fact]
+ public void HasFlag_AbsentFlag_ReturnsFalse()
+ {
+ var args = new FilterArguments(null, new Dictionary());
+
+ Assert.False(args.HasFlag("fromEnd"));
+ }
+
+ [Fact]
+ public void HasFlag_KeyWithValue_ReturnsFalse()
+ {
+ var named = new Dictionary
+ {
+ ["suffix"] = new StringValue("px")
+ };
+ var args = new FilterArguments(null, named);
+
+ Assert.False(args.HasFlag("suffix"));
+ }
+
+ [Fact]
+ public void Empty_HasNoPositionalOrNamed()
+ {
+ var args = FilterArguments.Empty;
+
+ Assert.Null(args.Positional);
+ Assert.False(args.HasFlag("anything"));
+ Assert.IsType(args.GetNamed("anything", NullValue.Instance));
+ }
+}
diff --git a/tests/FlexRender.Tests/Expressions/FilterTests.cs b/tests/FlexRender.Tests/Expressions/FilterTests.cs
index fb75e66..1460b5f 100644
--- a/tests/FlexRender.Tests/Expressions/FilterTests.cs
+++ b/tests/FlexRender.Tests/Expressions/FilterTests.cs
@@ -26,7 +26,7 @@ public sealed class FilterTests
public void CurrencyFilter_FormatsNumberWithCommasAndTwoDecimals(decimal input, string expected)
{
var filter = new CurrencyFilter();
- var result = filter.Apply(new NumberValue(input), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(input), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -36,7 +36,7 @@ public void CurrencyFilter_FormatsNumberWithCommasAndTwoDecimals(decimal input,
public void CurrencyFilter_NullInput_ReturnsNullValue()
{
var filter = new CurrencyFilter();
- var result = filter.Apply(NullValue.Instance, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(NullValue.Instance, FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -45,7 +45,7 @@ public void CurrencyFilter_NullInput_ReturnsNullValue()
public void CurrencyFilter_StringInput_ReturnsNullValue()
{
var filter = new CurrencyFilter();
- var result = filter.Apply(new StringValue("not a number"), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue("not a number"), FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -82,7 +82,7 @@ public void CurrencyFilter_Name_IsCurrency()
public void CurrencySymbolFilter_AlphaCode_ReturnsSymbol(string code, string expectedSymbol)
{
var filter = new CurrencySymbolFilter();
- var result = filter.Apply(new StringValue(code), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(code), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expectedSymbol, str.Value);
@@ -97,7 +97,7 @@ public void CurrencySymbolFilter_AlphaCode_ReturnsSymbol(string code, string exp
public void CurrencySymbolFilter_CaseInsensitive_ReturnsSymbol(string code, string expectedSymbol)
{
var filter = new CurrencySymbolFilter();
- var result = filter.Apply(new StringValue(code), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(code), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expectedSymbol, str.Value);
@@ -120,7 +120,7 @@ public void CurrencySymbolFilter_CaseInsensitive_ReturnsSymbol(string code, stri
public void CurrencySymbolFilter_NumericCode_ReturnsSymbol(int code, string expectedSymbol)
{
var filter = new CurrencySymbolFilter();
- var result = filter.Apply(new NumberValue(code), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(code), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expectedSymbol, str.Value);
@@ -131,7 +131,7 @@ public void CurrencySymbolFilter_UnknownAlphaCode_ReturnsInputUnchanged()
{
var filter = new CurrencySymbolFilter();
var input = new StringValue("XYZ");
- var result = filter.Apply(input, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("XYZ", str.Value);
@@ -142,7 +142,7 @@ public void CurrencySymbolFilter_UnknownNumericCode_ReturnsInputUnchanged()
{
var filter = new CurrencySymbolFilter();
var input = new NumberValue(999);
- var result = filter.Apply(input, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
var num = Assert.IsType(result);
Assert.Equal(999m, num.Value);
@@ -152,7 +152,7 @@ public void CurrencySymbolFilter_UnknownNumericCode_ReturnsInputUnchanged()
public void CurrencySymbolFilter_NullInput_ReturnsInputUnchanged()
{
var filter = new CurrencySymbolFilter();
- var result = filter.Apply(NullValue.Instance, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(NullValue.Instance, FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -162,7 +162,7 @@ public void CurrencySymbolFilter_BoolInput_ReturnsInputUnchanged()
{
var filter = new CurrencySymbolFilter();
var input = new BoolValue(true);
- var result = filter.Apply(input, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -184,7 +184,7 @@ public void CurrencySymbolFilter_Name_IsCurrencySymbol()
public void NumberFilter_FormatsWithSpecifiedDecimals(decimal input, string decimals, string expected)
{
var filter = new NumberFilter();
- var result = filter.Apply(new NumberValue(input), new StringValue(decimals), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(input), Args(decimals), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -194,7 +194,7 @@ public void NumberFilter_FormatsWithSpecifiedDecimals(decimal input, string deci
public void NumberFilter_NoArgument_DefaultsToZeroDecimals()
{
var filter = new NumberFilter();
- var result = filter.Apply(new NumberValue(1234.567m), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(1234.567m), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("1235", str.Value);
@@ -217,7 +217,7 @@ public void NumberFilter_Name_IsNumber()
public void UpperFilter_ConvertsToUpperCase(string input, string expected)
{
var filter = new UpperFilter();
- var result = filter.Apply(new StringValue(input), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(input), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -227,7 +227,7 @@ public void UpperFilter_ConvertsToUpperCase(string input, string expected)
public void UpperFilter_NullInput_ReturnsNullValue()
{
var filter = new UpperFilter();
- var result = filter.Apply(NullValue.Instance, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(NullValue.Instance, FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -236,7 +236,7 @@ public void UpperFilter_NullInput_ReturnsNullValue()
public void UpperFilter_NumberInput_ReturnsUnchanged()
{
var filter = new UpperFilter();
- var result = filter.Apply(new NumberValue(42), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(42), FilterArguments.Empty, CultureInfo.InvariantCulture);
// Non-string input is returned unchanged
var num = Assert.IsType(result);
@@ -260,7 +260,7 @@ public void UpperFilter_Name_IsUpper()
public void LowerFilter_ConvertsToLowerCase(string input, string expected)
{
var filter = new LowerFilter();
- var result = filter.Apply(new StringValue(input), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(input), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -283,7 +283,7 @@ public void LowerFilter_Name_IsLower()
public void TrimFilter_RemovesLeadingAndTrailingWhitespace(string input, string expected)
{
var filter = new TrimFilter();
- var result = filter.Apply(new StringValue(input), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(input), FilterArguments.Empty, CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -304,7 +304,7 @@ public void TruncateFilter_LongString_TruncatesWithEllipsis()
var filter = new TruncateFilter();
var result = filter.Apply(
new StringValue("This is a very long string that needs truncation"),
- new StringValue("20"),
+ Args("20"),
CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
@@ -316,7 +316,7 @@ public void TruncateFilter_LongString_TruncatesWithEllipsis()
public void TruncateFilter_ShortString_NoChange()
{
var filter = new TruncateFilter();
- var result = filter.Apply(new StringValue("Short"), new StringValue("30"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue("Short"), Args("30"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("Short", str.Value);
@@ -326,7 +326,7 @@ public void TruncateFilter_ShortString_NoChange()
public void TruncateFilter_ExactLength_NoChange()
{
var filter = new TruncateFilter();
- var result = filter.Apply(new StringValue("12345"), new StringValue("5"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue("12345"), Args("5"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("12345", str.Value);
@@ -339,6 +339,150 @@ public void TruncateFilter_Name_IsTruncate()
Assert.Equal("truncate", filter.Name);
}
+ [Fact]
+ public void TruncateFilter_CustomSuffix_UsesSuffix()
+ {
+ var filter = new TruncateFilter();
+ var named = new Dictionary { ["suffix"] = new StringValue("\u2026") };
+ var args = new FilterArguments(new StringValue("10"), named);
+ var result = filter.Apply(new StringValue("Hello, World!"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(10, str.Value.Length);
+ Assert.EndsWith("\u2026", str.Value);
+ Assert.Equal("Hello, Wo\u2026", str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_FromEnd_KeepsLastChars()
+ {
+ var filter = new TruncateFilter();
+ var named = new Dictionary { ["fromEnd"] = null };
+ var args = new FilterArguments(new StringValue("8"), named);
+ var result = filter.Apply(new StringValue("Hello, World!"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(8, str.Value.Length);
+ Assert.Equal("...orld!", str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_FromEndWithCustomSuffix_Works()
+ {
+ var filter = new TruncateFilter();
+ var named = new Dictionary
+ {
+ ["fromEnd"] = null,
+ ["suffix"] = new StringValue("\u2026")
+ };
+ var args = new FilterArguments(new StringValue("8"), named);
+ var result = filter.Apply(new StringValue("Hello, World!"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(8, str.Value.Length);
+ Assert.Equal("\u2026 World!", str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_EmptySuffix_NoSuffix()
+ {
+ var filter = new TruncateFilter();
+ var named = new Dictionary { ["suffix"] = new StringValue("") };
+ var args = new FilterArguments(new StringValue("5"), named);
+ var result = filter.Apply(new StringValue("Hello, World!"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("Hello", str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_NamedLength_OverridesPositional()
+ {
+ var filter = new TruncateFilter();
+ var named = new Dictionary { ["length"] = new StringValue("5") };
+ var args = new FilterArguments(new StringValue("30"), named);
+ var result = filter.Apply(new StringValue("Hello, World!"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(5, str.Value.Length);
+ }
+
+ [Fact]
+ public void TruncateFilter_NumberInput_ConvertedToString()
+ {
+ var filter = new TruncateFilter();
+ var args = new FilterArguments(new StringValue("5"), new Dictionary());
+ var result = filter.Apply(new NumberValue(12345.678m), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(5, str.Value.Length);
+ }
+
+ [Fact]
+ public void TruncateFilter_BoolInput_ConvertedToString()
+ {
+ var filter = new TruncateFilter();
+ var args = new FilterArguments(new StringValue("2"), new Dictionary());
+ var result = filter.Apply(new BoolValue(true), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("..", str.Value); // "true" len 4 > 2, suffix "..." truncated to 2
+ }
+
+ [Fact]
+ public void TruncateFilter_NullInput_ReturnsEmptyString()
+ {
+ var filter = new TruncateFilter();
+ var result = filter.Apply(NullValue.Instance, FilterArguments.Empty, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("", str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_ArrayInput_ReturnsAsIs()
+ {
+ var filter = new TruncateFilter();
+ var input = new ArrayValue([new StringValue("a")]);
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void TruncateFilter_ObjectInput_ReturnsAsIs()
+ {
+ var filter = new TruncateFilter();
+ var input = new ObjectValue { ["k"] = new StringValue("v") };
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void TruncateFilter_SuffixTooLong_ClampedTo100()
+ {
+ var filter = new TruncateFilter();
+ var longSuffix = new string('.', 200);
+ var named = new Dictionary { ["suffix"] = new StringValue(longSuffix) };
+ var args = new FilterArguments(new StringValue("50"), named);
+ var result = filter.Apply(new StringValue(new string('A', 200)), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(50, str.Value.Length);
+ }
+
+ [Fact]
+ public void TruncateFilter_LengthShorterThanSuffix_SuffixTruncated()
+ {
+ var filter = new TruncateFilter();
+ var args = new FilterArguments(new StringValue("2"), new Dictionary());
+ var result = filter.Apply(new StringValue("Hello"), args, CultureInfo.InvariantCulture);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("..", str.Value);
+ }
+
// === FormatFilter ===
[Fact]
@@ -356,7 +500,7 @@ public void FormatFilter_Name_IsFormat()
public void FormatFilter_NumberValue_FormatsWithFormatString(decimal input, string format, string expected)
{
var filter = new FormatFilter();
- var result = filter.Apply(new NumberValue(input), new StringValue(format), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(input), Args(format), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -369,7 +513,7 @@ public void FormatFilter_NumberValue_FormatsWithFormatString(decimal input, stri
public void FormatFilter_DateString_ParsesAndFormats(string input, string format, string expected)
{
var filter = new FormatFilter();
- var result = filter.Apply(new StringValue(input), new StringValue(format), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue(input), Args(format), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal(expected, str.Value);
@@ -380,7 +524,7 @@ public void FormatFilter_NoArgument_ReturnsInputUnchanged()
{
var filter = new FormatFilter();
var input = new NumberValue(42);
- var result = filter.Apply(input, null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, FilterArguments.Empty, CultureInfo.InvariantCulture);
var num = Assert.IsType(result);
Assert.Equal(42m, num.Value);
@@ -391,7 +535,7 @@ public void FormatFilter_EmptyArgument_ReturnsInputUnchanged()
{
var filter = new FormatFilter();
var input = new NumberValue(42);
- var result = filter.Apply(input, new StringValue(""), CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, Args(""), CultureInfo.InvariantCulture);
var num = Assert.IsType(result);
Assert.Equal(42m, num.Value);
@@ -401,7 +545,7 @@ public void FormatFilter_EmptyArgument_ReturnsInputUnchanged()
public void FormatFilter_NullInput_ReturnsNullUnchanged()
{
var filter = new FormatFilter();
- var result = filter.Apply(NullValue.Instance, new StringValue("F2"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(NullValue.Instance, Args("F2"), CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -411,7 +555,7 @@ public void FormatFilter_NonParsableDateString_ReturnsInputUnchanged()
{
var filter = new FormatFilter();
var input = new StringValue("not a date");
- var result = filter.Apply(input, new StringValue("dd.MM.yyyy"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(input, Args("dd.MM.yyyy"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("not a date", str.Value);
@@ -423,7 +567,7 @@ public void FormatFilter_LongFormatString_TruncatedToMaxLength()
var filter = new FormatFilter();
var longFormat = new string('0', 200);
// Should not throw, just truncates the format to 100 chars
- var result = filter.Apply(new NumberValue(42), new StringValue(longFormat), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(42), Args(longFormat), CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -433,7 +577,7 @@ public void FormatFilter_WithRussianCulture_FormatsDateInRussian()
{
var filter = new FormatFilter();
var ruCulture = CultureInfo.GetCultureInfo("ru-RU");
- var result = filter.Apply(new StringValue("2026-02-07"), new StringValue("dd MMMM yyyy"), ruCulture);
+ var result = filter.Apply(new StringValue("2026-02-07"), Args("dd MMMM yyyy"), ruCulture);
var str = Assert.IsType(result);
// Russian month name for February
@@ -445,7 +589,7 @@ public void FormatFilter_NumberWithGermanCulture_UsesCommaDecimalSeparator()
{
var filter = new FormatFilter();
var deCulture = CultureInfo.GetCultureInfo("de-DE");
- var result = filter.Apply(new NumberValue(1234.56m), new StringValue("F2"), deCulture);
+ var result = filter.Apply(new NumberValue(1234.56m), Args("F2"), deCulture);
var str = Assert.IsType(result);
Assert.Equal("1234,56", str.Value);
@@ -516,7 +660,7 @@ public void CurrencyFilter_WithRussianCulture_UsesSpaceSeparatorAndComma()
var filter = new CurrencyFilter();
var ruCulture = CultureInfo.GetCultureInfo("ru-RU");
- var result = filter.Apply(new NumberValue(1234.56m), null, ruCulture);
+ var result = filter.Apply(new NumberValue(1234.56m), FilterArguments.Empty, ruCulture);
var str = Assert.IsType(result);
// ru-RU uses non-breaking space as group separator and comma as decimal separator
@@ -530,7 +674,7 @@ public void NumberFilter_WithGermanCulture_UsesCommaDecimalSeparator()
var filter = new NumberFilter();
var deCulture = CultureInfo.GetCultureInfo("de-DE");
- var result = filter.Apply(new NumberValue(1234.56m), new StringValue("2"), deCulture);
+ var result = filter.Apply(new NumberValue(1234.56m), Args("2"), deCulture);
var str = Assert.IsType(result);
Assert.Equal("1234,56", str.Value);
@@ -542,7 +686,7 @@ public void UpperFilter_WithTurkishCulture_HandlesDottedI()
var filter = new UpperFilter();
var trCulture = CultureInfo.GetCultureInfo("tr-TR");
- var result = filter.Apply(new StringValue("istanbul"), null, trCulture);
+ var result = filter.Apply(new StringValue("istanbul"), FilterArguments.Empty, trCulture);
var str = Assert.IsType(result);
// Turkish uppercase I-without-dot = \u0130
@@ -555,7 +699,7 @@ public void LowerFilter_WithTurkishCulture_HandlesDottedI()
var filter = new LowerFilter();
var trCulture = CultureInfo.GetCultureInfo("tr-TR");
- var result = filter.Apply(new StringValue("I"), null, trCulture);
+ var result = filter.Apply(new StringValue("I"), FilterArguments.Empty, trCulture);
var str = Assert.IsType(result);
// Turkish lowercase I = \u0131 (dotless i)
@@ -568,7 +712,7 @@ public void CurrencySymbolFilter_NotAffectedByCulture()
var filter = new CurrencySymbolFilter();
var ruCulture = CultureInfo.GetCultureInfo("ru-RU");
- var result = filter.Apply(new StringValue("USD"), null, ruCulture);
+ var result = filter.Apply(new StringValue("USD"), FilterArguments.Empty, ruCulture);
var str = Assert.IsType(result);
Assert.Equal("$", str.Value);
@@ -580,7 +724,7 @@ public void TrimFilter_NotAffectedByCulture()
var filter = new TrimFilter();
var jpCulture = CultureInfo.GetCultureInfo("ja-JP");
- var result = filter.Apply(new StringValue(" hello "), null, jpCulture);
+ var result = filter.Apply(new StringValue(" hello "), FilterArguments.Empty, jpCulture);
var str = Assert.IsType(result);
Assert.Equal("hello", str.Value);
@@ -592,7 +736,7 @@ public void CurrencyFilter_EnUs_FormatsWithCommaAndDot()
var filter = new CurrencyFilter();
var enCulture = CultureInfo.GetCultureInfo("en-US");
- var result = filter.Apply(new NumberValue(1234.56m), null, enCulture);
+ var result = filter.Apply(new NumberValue(1234.56m), FilterArguments.Empty, enCulture);
var str = Assert.IsType(result);
Assert.Equal("1,234.56", str.Value);
@@ -604,7 +748,7 @@ public void NumberFilter_WithRussianCulture_UsesCommaDecimalSeparator()
var filter = new NumberFilter();
var ruCulture = CultureInfo.GetCultureInfo("ru-RU");
- var result = filter.Apply(new NumberValue(1234.56m), new StringValue("2"), ruCulture);
+ var result = filter.Apply(new NumberValue(1234.56m), Args("2"), ruCulture);
var str = Assert.IsType(result);
Assert.Equal("1234,56", str.Value);
@@ -614,7 +758,7 @@ public void NumberFilter_WithRussianCulture_UsesCommaDecimalSeparator()
public void NumberFilter_NegativeDecimals_ClampedToZero()
{
var filter = new NumberFilter();
- var result = filter.Apply(new NumberValue(1234.567m), new StringValue("-5"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(1234.567m), Args("-5"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
// Clamped to 0 decimals
@@ -625,7 +769,7 @@ public void NumberFilter_NegativeDecimals_ClampedToZero()
public void NumberFilter_ExcessiveDecimals_ClampedToMax()
{
var filter = new NumberFilter();
- var result = filter.Apply(new NumberValue(1.5m), new StringValue("100"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(1.5m), Args("100"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
// Clamped to MaxDecimalPlaces=20, so 20 decimal digits
@@ -636,7 +780,7 @@ public void NumberFilter_ExcessiveDecimals_ClampedToMax()
public void NumberFilter_InvalidDecimalArgument_DefaultsToZero()
{
var filter = new NumberFilter();
- var result = filter.Apply(new NumberValue(1234.567m), new StringValue("abc"), CultureInfo.InvariantCulture);
+ var result = filter.Apply(new NumberValue(1234.567m), Args("abc"), CultureInfo.InvariantCulture);
var str = Assert.IsType(result);
Assert.Equal("1235", str.Value);
@@ -646,7 +790,7 @@ public void NumberFilter_InvalidDecimalArgument_DefaultsToZero()
public void NumberFilter_NonNumberInput_ReturnsNullValue()
{
var filter = new NumberFilter();
- var result = filter.Apply(new StringValue("not a number"), null, CultureInfo.InvariantCulture);
+ var result = filter.Apply(new StringValue("not a number"), FilterArguments.Empty, CultureInfo.InvariantCulture);
Assert.IsType(result);
}
@@ -656,7 +800,7 @@ public void FormatFilter_WithEnUsCulture_FormatsDateInEnglish()
{
var filter = new FormatFilter();
var enCulture = CultureInfo.GetCultureInfo("en-US");
- var result = filter.Apply(new StringValue("2026-02-07"), new StringValue("MMMM d, yyyy"), enCulture);
+ var result = filter.Apply(new StringValue("2026-02-07"), Args("MMMM d, yyyy"), enCulture);
var str = Assert.IsType(result);
Assert.Equal("February 7, 2026", str.Value);
@@ -694,6 +838,51 @@ public void FilterRegistry_Register_OverridesByName()
Assert.Same(custom, retrieved);
}
+ // === End-to-end: parser + evaluator + filter ===
+
+ [Theory]
+ [InlineData("x | truncate:5", "Hello, World!", "He...")]
+ [InlineData("x | truncate:5 suffix:'…'", "Hello, World!", "Hell…")]
+ [InlineData("x | truncate:5 fromEnd", "Hello, World!", "...d!")]
+ [InlineData("x | truncate:5 fromEnd suffix:'…'", "Hello, World!", "…rld!")]
+ [InlineData("x | truncate:10", "Short", "Short")]
+ [InlineData("x | truncate length:5", "Hello, World!", "He...")]
+ [InlineData("x | truncate:30 length:5", "Hello, World!", "He...")] // named overrides positional
+ [InlineData("x | truncate:5 suffix:''", "Hello, World!", "Hello")]
+ [InlineData("x | trim | truncate:8", " Hello, World! ", "Hello...")]
+ public void TruncateFilter_E2E_FullPipeline(string expression, string inputValue, string expected)
+ {
+ var registry = FilterRegistry.CreateDefault();
+ var evaluator = new InlineExpressionEvaluator(registry);
+ var context = new TemplateContext(new ObjectValue { ["x"] = new StringValue(inputValue) });
+
+ var parsed = InlineExpressionParser.Parse(expression);
+ var result = evaluator.Evaluate(parsed, context);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(expected, str.Value);
+ }
+
+ [Fact]
+ public void TruncateFilter_E2E_NumberInput()
+ {
+ var registry = FilterRegistry.CreateDefault();
+ var evaluator = new InlineExpressionEvaluator(registry);
+ var context = new TemplateContext(new ObjectValue { ["x"] = new NumberValue(123456.789m) });
+
+ var parsed = InlineExpressionParser.Parse("x | truncate:8");
+ var result = evaluator.Evaluate(parsed, context);
+
+ var str = Assert.IsType(result);
+ Assert.Equal(8, str.Value.Length);
+ Assert.EndsWith("...", str.Value);
+ }
+
+ private static FilterArguments Args(string? positional = null) =>
+ positional is not null
+ ? new FilterArguments(new StringValue(positional), new Dictionary())
+ : FilterArguments.Empty;
+
///
/// Test helper filter for custom filter registration tests.
///
@@ -703,6 +892,6 @@ private sealed class TestFilter : ITemplateFilter
public TestFilter(string name) => Name = name;
- public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture) => input;
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture) => input;
}
}
diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs
index f9a6353..11c097e 100644
--- a/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs
+++ b/tests/FlexRender.Tests/Expressions/InlineExpressionEvaluatorTests.cs
@@ -3,6 +3,7 @@
// Compilation status: WILL NOT COMPILE until InlineExpressionEvaluator,
// InlineExpressionParser, and InlineExpression AST nodes are implemented.
+using System.Globalization;
using FlexRender.TemplateEngine;
using Xunit;
@@ -430,4 +431,50 @@ public void Evaluate_UnknownFilter_ThrowsTemplateEngineException()
Assert.Throws(
() => Evaluator.Evaluate(ast, context));
}
+
+ // === Named filter parameters ===
+
+ [Fact]
+ public void Evaluate_FilterWithNamedParam_PassesToFilter()
+ {
+ var registry = new FilterRegistry();
+ registry.Register(new TestNamedParamFilter());
+ var evaluator = new InlineExpressionEvaluator(registry);
+ var context = new TemplateContext(new ObjectValue { ["x"] = new StringValue("hello") });
+
+ var expr = InlineExpressionParser.Parse("x | testnamed:5 label:'world'");
+ var result = evaluator.Evaluate(expr, context);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("hello|5|world", str.Value);
+ }
+
+ [Fact]
+ public void Evaluate_FilterWithFlag_PassesToFilter()
+ {
+ var registry = new FilterRegistry();
+ registry.Register(new TestNamedParamFilter());
+ var evaluator = new InlineExpressionEvaluator(registry);
+ var context = new TemplateContext(new ObjectValue { ["x"] = new StringValue("hello") });
+
+ var expr = InlineExpressionParser.Parse("x | testnamed:5 reverse");
+ var result = evaluator.Evaluate(expr, context);
+
+ var str = Assert.IsType(result);
+ Assert.Equal("hello|5|reversed", str.Value);
+ }
+
+ private sealed class TestNamedParamFilter : ITemplateFilter
+ {
+ public string Name => "testnamed";
+
+ public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
+ {
+ var inputStr = input is StringValue sv ? sv.Value : "null";
+ var pos = arguments.Positional is StringValue ps ? ps.Value : "none";
+ var label = arguments.GetNamed("label", NullValue.Instance) is StringValue ls ? ls.Value : "none";
+ var reversed = arguments.HasFlag("reverse") ? "reversed" : label;
+ return new StringValue($"{inputStr}|{pos}|{reversed}");
+ }
+ }
}
diff --git a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs
index 2aacb40..ef2d2cb 100644
--- a/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs
+++ b/tests/FlexRender.Tests/Expressions/InlineExpressionParserTests.cs
@@ -953,4 +953,115 @@ public void Parse_TruePrefix_IsPath()
}
#endregion
+
+ #region Named Filter Parameters
+
+ [Fact]
+ public void Parse_FilterWithNamedParam_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate length:30");
+ var filter = Assert.IsType(expr);
+ Assert.Equal("truncate", filter.FilterName);
+ Assert.Null(filter.Argument);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Single(filter.NamedArguments);
+ Assert.Equal("length", filter.NamedArguments[0].Name);
+ Assert.Equal("30", filter.NamedArguments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_FilterWithFlag_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30 fromEnd");
+ var filter = Assert.IsType(expr);
+ Assert.Equal("30", filter.Argument);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Single(filter.NamedArguments);
+ Assert.Equal("fromEnd", filter.NamedArguments[0].Name);
+ Assert.Null(filter.NamedArguments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_FilterWithMixedArgs_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30 suffix:'\u2026' fromEnd");
+ var filter = Assert.IsType(expr);
+ Assert.Equal("30", filter.Argument);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Equal(2, filter.NamedArguments.Count);
+ Assert.Equal("suffix", filter.NamedArguments[0].Name);
+ Assert.Equal("\u2026", filter.NamedArguments[0].Value);
+ Assert.Equal("fromEnd", filter.NamedArguments[1].Name);
+ Assert.Null(filter.NamedArguments[1].Value);
+ }
+
+ [Fact]
+ public void Parse_FilterWithAllNamedParams_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate length:30 suffix:'\u2026'");
+ var filter = Assert.IsType(expr);
+ Assert.Null(filter.Argument);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Equal(2, filter.NamedArguments.Count);
+ }
+
+ [Fact]
+ public void Parse_FilterNamedQuotedString_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30 suffix:\"...\"");
+ var filter = Assert.IsType(expr);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Equal("...", filter.NamedArguments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_FilterEmptyStringValue_ProducesFilterExpression()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30 suffix:''");
+ var filter = Assert.IsType(expr);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Equal("", filter.NamedArguments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_FilterChainWithNamedParams_Works()
+ {
+ var expr = InlineExpressionParser.Parse("x | trim | truncate:30 fromEnd");
+ var outer = Assert.IsType(expr);
+ Assert.Equal("truncate", outer.FilterName);
+ Assert.NotNull(outer.NamedArguments);
+ var inner = Assert.IsType(outer.Input);
+ Assert.Equal("trim", inner.FilterName);
+ }
+
+ [Fact]
+ public void Parse_FilterWithoutNamedParams_BackwardCompatible()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30");
+ var filter = Assert.IsType(expr);
+ Assert.Equal("30", filter.Argument);
+ Assert.Null(filter.NamedArguments);
+ }
+
+ [Fact]
+ public void Parse_FilterNoArgs_BackwardCompatible()
+ {
+ var expr = InlineExpressionParser.Parse("x | upper");
+ var filter = Assert.IsType(expr);
+ Assert.Null(filter.Argument);
+ Assert.Null(filter.NamedArguments);
+ }
+
+ [Fact]
+ public void Parse_PositionalAndSameNamedParam_BothPresent()
+ {
+ var expr = InlineExpressionParser.Parse("x | truncate:30 length:20");
+ var filter = Assert.IsType(expr);
+ Assert.Equal("30", filter.Argument);
+ Assert.NotNull(filter.NamedArguments);
+ Assert.Equal("length", filter.NamedArguments[0].Name);
+ Assert.Equal("20", filter.NamedArguments[0].Value);
+ }
+
+ #endregion
}