From 25a68cd87113993ccde8cc4c3b9b7e81108eddbc Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:01:57 +0300 Subject: [PATCH 1/8] feat(filters): add FilterArguments class and update ITemplateFilter signature --- .../TemplateEngine/ITemplateFilter.cs | 60 +++++++++++- .../Expressions/FilterArgumentsTests.cs | 96 +++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/FlexRender.Tests/Expressions/FilterArgumentsTests.cs diff --git a/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs index 76649c9..37767ca 100644 --- a/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs +++ b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs @@ -2,6 +2,62 @@ namespace FlexRender.TemplateEngine; +/// +/// Holds parsed filter arguments — an optional positional argument plus named key:value pairs and flags. +/// +public sealed class FilterArguments +{ + /// + /// Empty arguments instance. Used when a filter has no arguments. + /// + public static readonly FilterArguments Empty = new(null, new Dictionary()); + + 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) + { + 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) + { + return _named.TryGetValue(name, out var value) && value is null; + } +} + /// /// Interface for template filters that transform values in filter pipe expressions. /// @@ -29,8 +85,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/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)); + } +} From 87837ebb04c7d29387d5c4a0d8cbdae77e19051a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:14:25 +0300 Subject: [PATCH 2/8] refactor(filters): migrate all filters to FilterArguments signature --- .../TemplateEngine/Filters/CurrencyFilter.cs | 2 +- .../Filters/CurrencySymbolFilter.cs | 2 +- .../TemplateEngine/Filters/FormatFilter.cs | 4 +- .../TemplateEngine/Filters/LowerFilter.cs | 2 +- .../TemplateEngine/Filters/NumberFilter.cs | 4 +- .../TemplateEngine/Filters/TrimFilter.cs | 2 +- .../TemplateEngine/Filters/TruncateFilter.cs | 4 +- .../TemplateEngine/Filters/UpperFilter.cs | 2 +- .../InlineExpressionEvaluator.cs | 8 +- .../Configuration/FlexRenderBuilderTests.cs | 2 +- .../Expressions/FilterTests.cs | 91 ++++++++++--------- 11 files changed, 66 insertions(+), 57 deletions(-) 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..1428606 100644 --- a/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs +++ b/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs @@ -25,7 +25,7 @@ public sealed class TruncateFilter : ITemplateFilter 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) { @@ -33,7 +33,7 @@ public TemplateValue Apply(TemplateValue input, TemplateValue? argument, Culture } var maxLen = 50; // default - if (argument is StringValue argStr && + if (arguments.Positional is StringValue argStr && int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { maxLen = Math.Clamp(parsed, 0, MaxLength); 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/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs index f2fece7..e1e3314 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs @@ -146,11 +146,15 @@ 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); + var arguments = positional is not null + ? new FilterArguments(positional, new Dictionary()) + : FilterArguments.Empty; + + return filter.Apply(input, arguments, _culture); } private TemplateValue EvaluateNegate(NegateExpression expr, TemplateContext context) 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/FilterTests.cs b/tests/FlexRender.Tests/Expressions/FilterTests.cs index fb75e66..da5d184 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); @@ -356,7 +356,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 +369,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 +380,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 +391,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 +401,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 +411,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 +423,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 +433,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 +445,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 +516,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 +530,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 +542,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 +555,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 +568,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 +580,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 +592,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 +604,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 +614,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 +625,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 +636,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 +646,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 +656,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 +694,11 @@ public void FilterRegistry_Register_OverridesByName() Assert.Same(custom, retrieved); } + 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 +708,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; } } From 46d66d025902ed610b02330b6cea65de533fab7b Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:19:19 +0300 Subject: [PATCH 3/8] feat(filters): add named parameter and flag parsing to filter syntax --- .../TemplateEngine/InlineExpression.cs | 20 +++- .../TemplateEngine/InlineExpressionParser.cs | 40 ++++++- .../InlineExpressionParserTests.cs | 111 ++++++++++++++++++ 3 files changed, 167 insertions(+), 4 deletions(-) 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/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/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 } From f61655eb2b665c6d6ec32e7029bb7a63f2ff2cf1 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:22:20 +0300 Subject: [PATCH 4/8] feat(filters): wire named parameters through evaluator to FilterArguments --- .../InlineExpressionEvaluator.cs | 20 ++++++-- .../InlineExpressionEvaluatorTests.cs | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs index e1e3314..56342c5 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs @@ -150,9 +150,23 @@ private TemplateValue EvaluateFilter(FilterExpression expr, TemplateContext cont ? new StringValue(expr.Argument) : null; - var arguments = positional is not null - ? new FilterArguments(positional, new Dictionary()) - : FilterArguments.Empty; + 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, new Dictionary()) + : FilterArguments.Empty; + } return filter.Apply(input, arguments, _culture); } 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}"); + } + } } From d5e0119b1f9a271c47842e24d6c82ca7cedc7182 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:29:58 +0300 Subject: [PATCH 5/8] feat(filters): enhance truncate with suffix, fromEnd, and type conversion --- .../TemplateEngine/Filters/TruncateFilter.cs | 89 +++++++++-- .../Expressions/FilterTests.cs | 144 ++++++++++++++++++ 2 files changed, 218 insertions(+), 15 deletions(-) diff --git a/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs b/src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs index 1428606..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,9 +31,14 @@ 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"; @@ -27,28 +46,68 @@ public sealed class TruncateFilter : ITemplateFilter /// 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 (arguments.Positional 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/tests/FlexRender.Tests/Expressions/FilterTests.cs b/tests/FlexRender.Tests/Expressions/FilterTests.cs index da5d184..f3bbdc7 100644 --- a/tests/FlexRender.Tests/Expressions/FilterTests.cs +++ b/tests/FlexRender.Tests/Expressions/FilterTests.cs @@ -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] From 5463cfef9bf4f3613eee9ac87b22563c4222c49c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:33:34 +0300 Subject: [PATCH 6/8] test: add end-to-end integration tests for named filter parameters --- .../Expressions/FilterTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/FlexRender.Tests/Expressions/FilterTests.cs b/tests/FlexRender.Tests/Expressions/FilterTests.cs index f3bbdc7..1460b5f 100644 --- a/tests/FlexRender.Tests/Expressions/FilterTests.cs +++ b/tests/FlexRender.Tests/Expressions/FilterTests.cs @@ -838,6 +838,46 @@ 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()) From cd1d778964603775d078f1cc0311bd1e23bff14f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:35:32 +0300 Subject: [PATCH 7/8] docs: update wiki with named filter parameters and enhanced truncate --- docs/wiki/Template-Expressions.md | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) 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 From f075cffe3ce687837485c92f9bb064d84d2a3301 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Thu, 26 Feb 2026 13:53:29 +0300 Subject: [PATCH 8/8] fix(filters): add null guards, immutable empty dict, reduce allocations - Add ArgumentNullException.ThrowIfNull(name) to GetNamed() and HasFlag() - Replace mutable Dictionary in FilterArguments.Empty with ReadOnlyDictionary - Reuse shared EmptyNamedDictionary in EvaluateFilter for positional-only case - Remove unrelated context7.json from feature branch --- context7.json | 4 ---- .../TemplateEngine/ITemplateFilter.cs | 13 ++++++++++++- .../TemplateEngine/InlineExpressionEvaluator.cs | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 context7.json 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/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs index 37767ca..db2fa9e 100644 --- a/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs +++ b/src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Globalization; namespace FlexRender.TemplateEngine; @@ -7,10 +8,18 @@ namespace FlexRender.TemplateEngine; /// 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, new Dictionary()); + 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; @@ -39,6 +48,7 @@ public FilterArguments(TemplateValue? positional, IReadOnlyDictionaryThe 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; @@ -54,6 +64,7 @@ public TemplateValue GetNamed(string name, TemplateValue defaultValue) /// 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; } } diff --git a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs index 56342c5..ef4ea6d 100644 --- a/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs +++ b/src/FlexRender.Core/TemplateEngine/InlineExpressionEvaluator.cs @@ -164,7 +164,7 @@ private TemplateValue EvaluateFilter(FilterExpression expr, TemplateContext cont else { arguments = positional is not null - ? new FilterArguments(positional, new Dictionary()) + ? new FilterArguments(positional, FilterArguments.EmptyNamedDictionary) : FilterArguments.Empty; }