diff --git a/Fluid.Tests/FromStatementTests.cs b/Fluid.Tests/FromStatementTests.cs index 17c17c50..22c981c6 100644 --- a/Fluid.Tests/FromStatementTests.cs +++ b/Fluid.Tests/FromStatementTests.cs @@ -62,7 +62,7 @@ Hello world! await fromStatement.WriteToAsync(sw, HtmlEncoder.Default, context); Assert.IsType(context.GetValue("hello_world")); - Assert.IsType(context.GetValue("hello")); + Assert.IsType(context.GetValue("hello")); } [Fact] diff --git a/Fluid.Tests/MiscFiltersTests.cs b/Fluid.Tests/MiscFiltersTests.cs index 8043d08b..04169db4 100644 --- a/Fluid.Tests/MiscFiltersTests.cs +++ b/Fluid.Tests/MiscFiltersTests.cs @@ -720,7 +720,7 @@ public async Task JsonShouldWriteNullIfDictionaryNotReturnFluidIndexable() options.MemberAccessStrategy.Register(model.GetType()); var input = FluidValue.Create(model, options); var result = await MiscFilters.Json(input, new FilterArguments(), new TemplateContext(options)); - Assert.Equal("{\"Id\":1,\"WithoutIndexable\":{\"Type\":5,\"Value\":{}},\"Bool\":true}", result.ToStringValue()); + Assert.Equal("{\"Id\":1,\"WithoutIndexable\":{\"Type\":6,\"Value\":{}},\"Bool\":true}", result.ToStringValue()); } [Fact] @@ -910,7 +910,7 @@ public async Task HmacSha1(string key, string value, string expected) { // Arrange FluidValue input = value is null - ? NilValue.Empty + ? EmptyValue.Instance : new StringValue(value); var arguments = new FilterArguments(FluidValue.Create(key, TemplateOptions.Default)); var context = new TemplateContext(); @@ -932,7 +932,7 @@ public async Task HmacSha256(string key, string value, string expected) { // Arrange FluidValue input = value is null - ? NilValue.Empty + ? EmptyValue.Instance : new StringValue(value); var arguments = new FilterArguments(FluidValue.Create(key, TemplateOptions.Default)); var context = new TemplateContext(); diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs new file mode 100644 index 00000000..ddf39cf5 --- /dev/null +++ b/Fluid.Tests/StrictVariableTests.cs @@ -0,0 +1,495 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Fluid.Tests.Domain; +using Fluid.Values; +using Xunit; + +namespace Fluid.Tests; + +public class StrictVariableTests +{ +#if COMPILED + private static readonly FluidParser _parser = new FluidParser().Compile(); +#else + private static readonly FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public async Task StrictVariables_DefaultBehaviorNoException() + { + // Verify missing variables don't throw by default + _parser.TryParse("{{ nonExistent }}", out var template, out var _); + var context = new TemplateContext(); + var result = await template.RenderAsync(context); + Assert.Equal("", result); + } + + [Fact] + public async Task UndefinedSimpleVariable_IsDetected() + { + _parser.TryParse("{{ nonExistingProperty }}", out var template, out var _); + + var options = new TemplateOptions(); + var context = new TemplateContext(options); + var detected = false; + context.Undefined = (path) => + { + Assert.Equal("nonExistingProperty", path); + detected = true; + return ValueTask.FromResult(NilValue.Instance); + }; + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.True(detected); + } + + [Fact] + public async Task UndefinedPropertyAccess_TracksMissingVariable() + { + _parser.TryParse("{{ user.nonExistingProperty }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + var context = new TemplateContext(options); + context.SetValue("user", new Person { Firstname = "John" }); + + await template.RenderAsync(context); + Assert.Contains("nonExistingProperty", missingVariables); + } + + [Fact] + public async Task MultipleMissingVariables_AllCollected() + { + _parser.TryParse("{{ var1 }} {{ var2 }} {{ var3 }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Equal(3, missingVariables.Count); + Assert.Contains("var1", missingVariables); + Assert.Contains("var2", missingVariables); + Assert.Contains("var3", missingVariables); + } + + [Fact] + public async Task MissingSubProperties_Tracked() + { + _parser.TryParse("{{ company.Director.Firstname }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + // Note: Not registering Employee type + var context = new TemplateContext(options); + context.SetValue("company", new Company { Director = new Employee { Firstname = "John" } }); + + await template.RenderAsync(context); + Assert.Single(missingVariables); + Assert.Contains("Firstname", missingVariables); + } + + [Fact] + public async Task NestedMissingProperties_Tracked() + { + _parser.TryParse("{{ company['Director.Firstname'] }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + // Note: Not registering Employee type + var context = new TemplateContext(options); + context.SetValue("company", new Company { Director = new Employee { Firstname = "John" } }); + + await template.RenderAsync(context); + Assert.Single(missingVariables); + Assert.Contains("Director.Firstname", missingVariables); + } + + [Fact] + public async Task MixedValidAndInvalidVariables_OnlyInvalidTracked() + { + _parser.TryParse("{{ validVar }} {{ invalidVar }} {{ anotherValid }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("validVar", "value1"); + context.SetValue("anotherValid", "value2"); + + await template.RenderAsync(context); + Assert.Single(missingVariables); + Assert.Contains("invalidVar", missingVariables); + Assert.DoesNotContain("validVar", missingVariables); + Assert.DoesNotContain("anotherValid", missingVariables); + } + + [Fact] + public async Task NoExceptionWhenAllVariablesExist() + { + _parser.TryParse("{{ name }} {{ age }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("name", "John"); + context.SetValue("age", 25); + + var result = await template.RenderAsync(context); + Assert.Equal("John 25", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_InIfConditions() + { + _parser.TryParse("{% if undefinedVar %}yes{% else %}no{% endif %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } + + [Fact] + public async Task StrictVariables_InForLoops() + { + _parser.TryParse("{% for item in undefinedCollection %}{{ item }}{% endfor %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedCollection", missingVariables); + } + + [Fact] + public async Task DuplicateMissingVariables_ListedAsManyTimes() + { + _parser.TryParse("{{ missing }} {{ missing }} {{ missing }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Equal(3, missingVariables.Count); + Assert.All(missingVariables, item => Assert.Equal("missing", item)); + } + + [Fact] + public async Task StrictVariables_WithModelFallback() + { + _parser.TryParse("{{ existingModelProp }} {{ nonExistentModelProp }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var model = new { existingModelProp = "value" }; + var context = new TemplateContext(model, options); + + await template.RenderAsync(context); + Assert.Contains("nonExistentModelProp", missingVariables); + } + + [Fact] + public async Task StrictVariables_WithFilters() + { + _parser.TryParse("{{ undefinedVar | upcase }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } + + [Fact] + public async Task MissingVariablesFormat_IsCorrect() + { + _parser.TryParse("{{ var1 }} {{ var2 }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("var1", missingVariables); + Assert.Contains("var2", missingVariables); + } + + [Fact] + public async Task RegisteredProperties_DontThrow() + { + _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + var context = new TemplateContext(options); + context.SetValue("person", new Person { Firstname = "John", Lastname = "Doe" }); + + var result = await template.RenderAsync(context); + Assert.Equal("John Doe", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_WithAssignment() + { + _parser.TryParse("{% assign x = undefinedVar %}{{ x }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } + + [Fact] + public async Task StrictVariables_WithCase() + { + _parser.TryParse("{% case undefinedVar %}{% when 1 %}one{% endcase %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } + + [Fact] + public async Task StrictVariables_EmptyStringNotMissing() + { + _parser.TryParse("{{ emptyString }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("emptyString", ""); + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_NullValueNotMissing() + { + _parser.TryParse("{{ nullValue }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("nullValue", (object)null); + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_NullMemberNotMissing() + { + _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + options.MemberAccessStrategy.Register(); + context.SetValue("person", new Person { Firstname = null, Lastname = "Doe" }); + + var result = await template.RenderAsync(context); + Assert.Equal(" Doe", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_WithBinaryExpression() + { + _parser.TryParse("{% if undefinedVar > 5 %}yes{% endif %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } + + [Fact] + public async Task StrictVariables_MultipleRenders_ClearsTracking() + { + _parser.TryParse("{{ missing }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + // First render should track missing variable + await template.RenderAsync(context); + Assert.Contains("missing", missingVariables); + + // Clear and set the variable + missingVariables.Clear(); + context.SetValue("missing", "value"); + + // Second render should succeed with no missing variables + var result = await template.RenderAsync(context); + Assert.Equal("value", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_ComplexTemplate() + { + var source = @" + {% for product in products %} + Name: {{ product.name }} + Price: {{ product.price }} + Stock: {{ product.stock }} + {% endfor %} + "; + + _parser.TryParse(source, out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + var products = new[] + { + new { name = "Product 1", price = 10 }, + new { name = "Product 2", price = 20 } + }; + context.SetValue("products", products); + + await template.RenderAsync(context); + Assert.Contains("stock", missingVariables); + } + + [Fact] + public async Task StrictVariables_WithElseIf() + { + _parser.TryParse("{% if false %}no{% elsif undefined %}maybe{% else %}yes{% endif %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefined", missingVariables); + } + + [Fact] + public async Task StrictVariables_ProducesOutputWithMissing() + { + _parser.TryParse("Start {{ missing }} End", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + // Should track missing variable and produce output + var result = await template.RenderAsync(context); + Assert.Equal("Start End", result); + Assert.Contains("missing", missingVariables); + } + + [Fact] + public async Task StrictVariables_WithRange() + { + _parser.TryParse("{% for i in (1..5) %}{{ i }}{% endfor %}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + // Should work fine - no missing variables + var result = await template.RenderAsync(context); + Assert.Equal("12345", result); + Assert.Empty(missingVariables); + } + + [Fact] + public async Task StrictVariables_WithCapture() + { + _parser.TryParse("{% capture foo %}{{ bar }}{% endcapture %}{{ foo }}", out var template, out var _); + + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("bar", missingVariables); + } + + [Fact] + public async Task UndefinedDelegate_ReceivesNotifications() + { + _parser.TryParse("{{ first }} {{ first }} {{ second }}", out var template, out var _); + + var paths = new List(); + var options = new TemplateOptions + { + Undefined = name => + { + paths.Add(name); + return ValueTask.FromResult(NilValue.Instance); + } + }; + + var context = new TemplateContext(options); + + var result = await template.RenderAsync(context); + Assert.True(string.IsNullOrWhiteSpace(result)); + + Assert.Equal(3, paths.Count); + Assert.Equal("first", paths[0]); + Assert.Equal("first", paths[1]); + Assert.Equal("second", paths[2]); + } + + [Fact] + public async Task UndefinedDelegate_CalledForMissingVariable() + { + _parser.TryParse("{{ missing }}", out var template, out var _); + + var paths = new List(); + var options = new TemplateOptions + { + Undefined = name => + { + paths.Add(name); + return ValueTask.FromResult(NilValue.Instance); + } + }; + + var context = new TemplateContext(options); + + await template.RenderAsync(context); + + Assert.Single(paths); + Assert.Equal("missing", paths[0]); + } + + [Fact] + public async Task UndefinedDelegate_CanReturnCustomValue() + { + _parser.TryParse("{{ missing }} {{ another }}", out var template, out var _); + + var options = new TemplateOptions + { + Undefined = name => + { + // Return a custom default value for undefined variables + return ValueTask.FromResult(new StringValue($"[{name} not found]")); + } + }; + + var context = new TemplateContext(options); + + var result = await template.RenderAsync(context); + Assert.Equal("[missing not found] [another not found]", result); + } + + private (TemplateOptions, List) CreateStrictOptions() + { + var missingVariables = new List(); + + var options = new TemplateOptions + { + Undefined = name => + { + missingVariables.Add(name); + return ValueTask.FromResult(NilValue.Instance); + } + }; + + return (options, missingVariables); + } +} + diff --git a/Fluid.Tests/TemplateContextTests.cs b/Fluid.Tests/TemplateContextTests.cs index 27c58957..0728eeee 100644 --- a/Fluid.Tests/TemplateContextTests.cs +++ b/Fluid.Tests/TemplateContextTests.cs @@ -192,6 +192,85 @@ public void ShouldUseTemplateOptionsStringComparerWithCaseSensitive() Assert.Equal("lowerupper", context.GetValue("case").ToStringValue() + context.GetValue("CASE").ToStringValue()); } + [Fact] + public void SetValue_WithNull_ShouldUseNilValue() + { + // This test verifies that setting null uses NilValue.Instance + // Changing to EmptyValue.Instance would break equality semantics + var context = new TemplateContext(); + context.SetValue("nullVar", (object)null); + + var value = context.GetValue("nullVar"); + + // NilValue equals NilValue.Instance + Assert.True(value.Equals(NilValue.Instance)); + + // NilValue does NOT equal EmptyValue.Instance + Assert.False(value.Equals(EmptyValue.Instance)); + + // NilValue converts to boolean false + Assert.False(value.ToBooleanValue()); + } + + [Fact] + public async Task SetValue_WithNull_NilEqualityInTemplates() + { + // This test verifies nil equality behavior in templates + // EmptyValue has different equality semantics than NilValue + _parser.TryParse("{% if nullVar == nil %}nil{% endif %}{% if nullVar == empty %}empty{% endif %}", out var template, out var _); + + var context = new TemplateContext(); + context.SetValue("nullVar", (object)null); + + var result = await template.RenderAsync(context); + Assert.Equal("nil", result); + } + + [Fact] + public async Task SetValue_WithUndefined_NilEqualityInTemplates() + { + // This test verifies nil equality behavior in templates + // EmptyValue has different equality semantics than NilValue + _parser.TryParse("{% if nullVar == nil %}nil{% endif %}{% if nullVar == empty %}empty{% endif %}", out var template, out var _); + + var context = new TemplateContext(); + + var result = await template.RenderAsync(context); + Assert.Equal("nil", result); + } + + [Fact] + public async Task SetValue_WithNull_BooleanConversionInTemplates() + { + // This test verifies that null values are falsy in conditionals + // EmptyValue.ToBooleanValue() returns true, NilValue returns false + _parser.TryParse("{% if nullVar %}truthy{% else %}falsy{% endif %}", out var template, out var _); + + var context = new TemplateContext(); + context.SetValue("nullVar", (object)null); + + var result = await template.RenderAsync(context); + + // With NilValue, null is falsy and should render "falsy" + // With EmptyValue, it would render "truthy" + Assert.Equal("falsy", result); + } + + [Fact] + public async Task SetValue_WithNull_UnlessConditional() + { + // This test verifies unless conditional with null values + _parser.TryParse("{% unless nullVar %}rendered{% endunless %}", out var template, out var _); + + var context = new TemplateContext(); + context.SetValue("nullVar", (object)null); + + var result = await template.RenderAsync(context); + + // With NilValue (falsy), unless should render the content + Assert.Equal("rendered", result); + } + private class TestClass { public string Name { get; set; } diff --git a/Fluid/Ast/MemberExpression.cs b/Fluid/Ast/MemberExpression.cs index 0d2dfac4..e04d50b1 100644 --- a/Fluid/Ast/MemberExpression.cs +++ b/Fluid/Ast/MemberExpression.cs @@ -44,6 +44,12 @@ public override ValueTask EvaluateAsync(TemplateContext context) { if (context.Model == null) { + // Check equality as IsNil() is also true for UndefinedValue + if (context.Undefined is not null && value == UndefinedValue.Instance) + { + return context.Undefined.Invoke(initial.Identifier); + } + return value; } diff --git a/Fluid/Filters/ColorFilters.cs b/Fluid/Filters/ColorFilters.cs index 7764618e..99123290 100644 --- a/Fluid/Filters/ColorFilters.cs +++ b/Fluid/Filters/ColorFilters.cs @@ -42,7 +42,7 @@ public static ValueTask ToRgb(FluidValue input, FilterArguments argu } else { - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -63,7 +63,7 @@ public static ValueTask ToHex(FluidValue input, FilterArguments argu } else { - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -84,7 +84,7 @@ public static ValueTask ToHsl(FluidValue input, FilterArguments argu } else { - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -108,7 +108,7 @@ public static ValueTask ColorExtract(FluidValue input, FilterArgumen } else { - return NilValue.Empty; + return EmptyValue.Instance; } return arguments.At(0).ToStringValue() switch @@ -120,7 +120,7 @@ public static ValueTask ColorExtract(FluidValue input, FilterArgumen "hue" => new StringValue(hslColor.H.ToString(CultureInfo.InvariantCulture)), "saturation" => new StringValue(Convert.ToInt32(hslColor.S * 100.0).ToString(CultureInfo.InvariantCulture)), "lightness" => new StringValue(Convert.ToInt32(hslColor.L * 100.0).ToString(CultureInfo.InvariantCulture)), - _ => NilValue.Empty, + _ => EmptyValue.Instance, }; } @@ -150,7 +150,7 @@ public static ValueTask ColorModify(FluidValue input, FilterArgument } else { - return NilValue.Empty; + return EmptyValue.Instance; } var modifiedValue = arguments.At(1).ToNumberValue(); @@ -167,7 +167,7 @@ public static ValueTask ColorModify(FluidValue input, FilterArgument "hue" => new StringValue(((RgbColor)new HslColor((int)modifiedValue, hslColor.S, hslColor.L, hslColor.A)).ToString()), "saturation" => new StringValue(((RgbColor)new HslColor(hslColor.H, (double)modifiedValue / 100.0, hslColor.L, hslColor.A)).ToString()), "lightness" => new StringValue(((RgbColor)new HslColor(hslColor.H, hslColor.S, (double)modifiedValue / 100.0, hslColor.A)).ToString()), - _ => NilValue.Empty, + _ => EmptyValue.Instance, }; } else if (isHsl) @@ -183,7 +183,7 @@ public static ValueTask ColorModify(FluidValue input, FilterArgument "hue" => new StringValue(new HslColor((int)modifiedValue, hslColor.S, hslColor.L, hslColor.A).ToString()), "saturation" => new StringValue(new HslColor(hslColor.H, (double)modifiedValue / 100.0, hslColor.L, hslColor.A).ToString()), "lightness" => new StringValue(new HslColor(hslColor.H, hslColor.S, (double)modifiedValue / 100.0, hslColor.A).ToString()), - _ => NilValue.Empty, + _ => EmptyValue.Instance, }; } else if (isHex) @@ -200,13 +200,13 @@ public static ValueTask ColorModify(FluidValue input, FilterArgument "hue" => new StringValue(((HexColor)new HslColor((int)modifiedValue, hslColor.S, hslColor.L, hslColor.A)).ToString()), "saturation" => new StringValue(((HexColor)new HslColor(hslColor.H, (double)modifiedValue / 100.0, hslColor.L, hslColor.A)).ToString()), "lightness" => new StringValue(((HexColor)new HslColor(hslColor.H, hslColor.S, (double)modifiedValue / 100.0, hslColor.A)).ToString()), - _ => NilValue.Empty, + _ => EmptyValue.Instance, }; } else { // The code is unreachable - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -228,7 +228,7 @@ public static ValueTask CalculateBrightness(FluidValue input, Filter } else { - return NilValue.Empty; + return EmptyValue.Instance; } var brightness = Convert.ToDouble(rgbColor.R * 299 + rgbColor.G * 587 + rgbColor.B * 114) / 1000.0; @@ -258,7 +258,7 @@ public static ValueTask ColorSaturate(FluidValue input, FilterArgume } else { - return NilValue.Empty; + return EmptyValue.Instance; } if (isHex) @@ -286,7 +286,7 @@ public static ValueTask ColorSaturate(FluidValue input, FilterArgume else { // The code is unreachable - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -312,7 +312,7 @@ public static ValueTask ColorDesaturate(FluidValue input, FilterArgu } else { - return NilValue.Empty; + return EmptyValue.Instance; } if (isHex) @@ -340,7 +340,7 @@ public static ValueTask ColorDesaturate(FluidValue input, FilterArgu else { // The code is unreachable - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -366,7 +366,7 @@ public static ValueTask ColorLighten(FluidValue input, FilterArgumen } else { - return NilValue.Empty; + return EmptyValue.Instance; } if (isHex) @@ -394,7 +394,7 @@ public static ValueTask ColorLighten(FluidValue input, FilterArgumen else { // The code is unreachable - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -421,7 +421,7 @@ public static ValueTask ColorDarken(FluidValue input, FilterArgument } else { - return NilValue.Empty; + return EmptyValue.Instance; } if (isHex) @@ -449,7 +449,7 @@ public static ValueTask ColorDarken(FluidValue input, FilterArgument else { // The code is unreachable - return NilValue.Empty; + return EmptyValue.Instance; } } @@ -459,7 +459,7 @@ public static ValueTask GetColorDifference(FluidValue input, FilterA var rgbColor2 = GetRgbColor(arguments.At(0).ToStringValue()); if (rgbColor1.Equals(RgbColor.Empty) || rgbColor2.Equals(RgbColor.Empty)) { - return NilValue.Empty; + return EmptyValue.Instance; } else { @@ -477,7 +477,7 @@ public static ValueTask GetColorBrightnessDifference(FluidValue inpu var rgbColor2 = GetRgbColor(arguments.At(0).ToStringValue()); if (rgbColor1.Equals(RgbColor.Empty) || rgbColor2.Equals(RgbColor.Empty)) { - return NilValue.Empty; + return EmptyValue.Instance; } else { @@ -495,7 +495,7 @@ public static ValueTask GetColorContrast(FluidValue input, FilterArg var rgbColor2 = GetRgbColor(arguments.At(0).ToStringValue()); if (rgbColor1.Equals(RgbColor.Empty) || rgbColor2.Equals(RgbColor.Empty)) { - return NilValue.Empty; + return EmptyValue.Instance; } else { diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index 7d9b5981..5859bcc7 100644 --- a/Fluid/Parser/FluidTemplate.cs +++ b/Fluid/Parser/FluidTemplate.cs @@ -1,5 +1,5 @@ -using Fluid.Ast; -using System.Text.Encodings.Web; +using System.Text.Encodings.Web; +using Fluid.Ast; namespace Fluid.Parser { diff --git a/Fluid/Scope.cs b/Fluid/Scope.cs index 97d2ce81..9fe768dc 100644 --- a/Fluid/Scope.cs +++ b/Fluid/Scope.cs @@ -1,5 +1,5 @@ -using Fluid.Values; using System.Runtime.CompilerServices; +using Fluid.Values; namespace Fluid { @@ -61,7 +61,7 @@ public FluidValue GetValue(string name) return Parent != null ? Parent.GetValue(name) - : NilValue.Instance; + : UndefinedValue.Instance; } /// diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index 7b5b2407..6c95215d 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -1,289 +1,295 @@ -using Fluid.Values; -using System.Globalization; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace Fluid -{ - public class TemplateContext - { - protected int _recursion; - protected int _steps; - - /// - /// Initializes a new instance of . - /// - public TemplateContext() : this(TemplateOptions.Default) - { - } - - /// - /// Initializes a new instance of . - /// - /// The model. - /// The template options. - /// Whether the members of the model can be accessed by default. - public TemplateContext(object model, TemplateOptions options, bool allowModelMembers = true) : this(options) - { - if (model == null) - { - ExceptionHelper.ThrowArgumentNullException(nameof(model)); - } - - if (model is FluidValue fluidValue) - { - Model = fluidValue; - } - else - { - Model = FluidValue.Create(model, options); - AllowModelMembers = allowModelMembers; - } - } - - /// - /// Initializes a new instance of with the specified . - /// - /// The template options. - /// An optional instance used when comparing model names. - public TemplateContext(TemplateOptions options, StringComparer modelNamesComparer = null) - { - modelNamesComparer ??= options.ModelNamesComparer; - - Options = options; - LocalScope = new Scope(options.Scope, forLoopScope: false, modelNamesComparer); - RootScope = LocalScope; - CultureInfo = options.CultureInfo; - TimeZone = options.TimeZone; - Captured = options.Captured; - Assigned = options.Assigned; - Now = options.Now; - MaxSteps = options.MaxSteps; - ModelNamesComparer = modelNamesComparer; - JsonSerializerOptions = options.JsonSerializerOptions; - } - - /// - /// Initializes a new instance of wih a model and option register its properties. - /// - /// The model. - /// Whether the members of the model can be accessed by default. - /// An optional instance used when comparing model names. - public TemplateContext(object model, bool allowModelMembers = true, StringComparer modelNamesComparer = null) : this(TemplateOptions.Default, modelNamesComparer) - { - if (model == null) - { - ExceptionHelper.ThrowArgumentNullException(nameof(model)); - } - - if (model is FluidValue fluidValue) - { - Model = fluidValue; - } - else - { - Model = FluidValue.Create(model, TemplateOptions.Default); - AllowModelMembers = allowModelMembers; - } - } - - /// - /// Gets the . - /// - public TemplateOptions Options { get; protected set; } - - /// - /// Gets or sets the maximum number of steps a script can execute. Leave to 0 for unlimited. - /// - public int MaxSteps { get; set; } = TemplateOptions.Default.MaxSteps; - - /// - /// Gets used when comparing model names. - /// - public StringComparer ModelNamesComparer { get; private set; } - - /// - /// Gets or sets the instance used to render locale values like dates and numbers. - /// - public CultureInfo CultureInfo { get; set; } = TemplateOptions.Default.CultureInfo; - - /// - /// Gets or sets the value to returned by the "now" keyword. - /// - public Func Now { get; set; } = TemplateOptions.Default.Now; - - /// - /// Gets or sets the local time zone used when parsing or creating dates without specific ones. - /// - public TimeZoneInfo TimeZone { get; set; } = TemplateOptions.Default.TimeZone; - - /// - /// Gets or sets the used by the json filter. - /// - public JsonSerializerOptions JsonSerializerOptions { get; set; } = TemplateOptions.Default.JsonSerializerOptions; - - /// - /// Increments the number of statements the current template is processing. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void IncrementSteps() - { - if (MaxSteps > 0 && _steps++ > MaxSteps) - { - ExceptionHelper.ThrowMaximumRecursionException(); - } - } - - /// - /// Gets or sets the current scope. - /// - internal Scope LocalScope { get; set; } - - /// - /// Gets or sets the root scope. - /// - internal Scope RootScope { get; set; } - - private Dictionary _ambientValues; - - /// - /// Used to define custom object on this instance to be used in filters and statements - /// but which are not available from the template. - /// - public Dictionary AmbientValues => _ambientValues ??= new Dictionary(); - - /// - /// Gets or sets a model object that is used to resolve properties in a template. This object is used if local and - /// global scopes are unsuccessful. - /// - public FluidValue Model { get; } - - /// - /// Whether the direct properties of the Model can be accessed without being registered. Default is true. - /// - public bool AllowModelMembers { get; set; } = true; - - /// - /// Gets or sets the delegate to execute when a Capture tag has been evaluated. - /// - public TemplateOptions.CapturedDelegate Captured { get; set; } - - /// - /// Gets or sets the delegate to execute when an Assign tag has been evaluated. - /// - public TemplateOptions.AssignedDelegate Assigned { get; set; } - - /// - /// Creates a new isolated child scope. After than any value added to this content object will be released once - /// is called. The previous scope is linked such that its values are still available. - /// - public void EnterChildScope() - { - if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) - { - ExceptionHelper.ThrowMaximumRecursionException(); - return; - } - - LocalScope = new Scope(LocalScope); - } - - /// - /// Creates a new for loop scope. After than any value added to this content object will be released once - /// is called. The previous scope is linked such that its values are still available. - /// - public void EnterForLoopScope() - { - if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) - { - ExceptionHelper.ThrowMaximumRecursionException(); - return; - } - - LocalScope = new Scope(LocalScope, forLoopScope: true); - } - - /// - /// Exits the current scope that has been created by - /// - public void ReleaseScope() - { - if (_recursion > 0) - { - _recursion--; - } - - LocalScope = LocalScope.Parent; - - if (LocalScope == null) - { - ExceptionHelper.ThrowInvalidOperationException("ReleaseScope invoked without corresponding EnterChildScope"); - return; - } - } - - /// - /// Gets the names of the values. - /// - public IEnumerable ValueNames => LocalScope.Properties; - - /// - /// Gets a value from the context. - /// - /// The name of the value. - public FluidValue GetValue(string name) - { - return LocalScope.GetValue(name); - } - - /// - /// Sets a value on the context. - /// - /// The name of the value. - /// The value to set. - /// - public TemplateContext SetValue(string name, FluidValue value) - { - LocalScope.SetValue(name, value); - return this; - } - } - - public static class TemplateContextExtensions - { - public static TemplateContext SetValue(this TemplateContext context, string name, int value) - { - return context.SetValue(name, NumberValue.Create(value)); - } - - public static TemplateContext SetValue(this TemplateContext context, string name, string value) - { - return context.SetValue(name, new StringValue(value)); - } - - public static TemplateContext SetValue(this TemplateContext context, string name, char value) - { - return context.SetValue(name, StringValue.Create(value)); - } - - public static TemplateContext SetValue(this TemplateContext context, string name, bool value) - { - return context.SetValue(name, BooleanValue.Create(value)); - } - - public static TemplateContext SetValue(this TemplateContext context, string name, object value) - { - if (value == null) - { - return context.SetValue(name, NilValue.Instance); - } - - return context.SetValue(name, FluidValue.Create(value, context.Options)); - } - - public static TemplateContext SetValue(this TemplateContext context, string name, Func factory) - { - return context.SetValue(name, new FactoryValue(factory)); - } - } -} +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Fluid.Values; + +namespace Fluid +{ + public class TemplateContext + { + protected int _recursion; + protected int _steps; + + /// + /// Initializes a new instance of . + /// + public TemplateContext() : this(TemplateOptions.Default) + { + } + + /// + /// Initializes a new instance of . + /// + /// The model. + /// The template options. + /// Whether the members of the model can be accessed by default. + public TemplateContext(object model, TemplateOptions options, bool allowModelMembers = true) : this(options) + { + if (model == null) + { + ExceptionHelper.ThrowArgumentNullException(nameof(model)); + } + + if (model is FluidValue fluidValue) + { + Model = fluidValue; + } + else + { + Model = FluidValue.Create(model, options); + AllowModelMembers = allowModelMembers; + } + } + + /// + /// Initializes a new instance of with the specified . + /// + /// The template options. + /// An optional instance used when comparing model names. + public TemplateContext(TemplateOptions options, StringComparer modelNamesComparer = null) + { + modelNamesComparer ??= options.ModelNamesComparer; + + Options = options; + LocalScope = new Scope(options.Scope, forLoopScope: false, modelNamesComparer); + RootScope = LocalScope; + CultureInfo = options.CultureInfo; + TimeZone = options.TimeZone; + Captured = options.Captured; + Assigned = options.Assigned; + Undefined = options.Undefined; + Now = options.Now; + MaxSteps = options.MaxSteps; + ModelNamesComparer = modelNamesComparer; + JsonSerializerOptions = options.JsonSerializerOptions; + } + + /// + /// Initializes a new instance of wih a model and option register its properties. + /// + /// The model. + /// Whether the members of the model can be accessed by default. + /// An optional instance used when comparing model names. + public TemplateContext(object model, bool allowModelMembers = true, StringComparer modelNamesComparer = null) : this(TemplateOptions.Default, modelNamesComparer) + { + if (model == null) + { + ExceptionHelper.ThrowArgumentNullException(nameof(model)); + } + + if (model is FluidValue fluidValue) + { + Model = fluidValue; + } + else + { + Model = FluidValue.Create(model, TemplateOptions.Default); + AllowModelMembers = allowModelMembers; + } + } + + /// + /// Gets the . + /// + public TemplateOptions Options { get; protected set; } + + /// + /// Gets or sets the maximum number of steps a script can execute. Leave to 0 for unlimited. + /// + public int MaxSteps { get; set; } = TemplateOptions.Default.MaxSteps; + + /// + /// Gets used when comparing model names. + /// + public StringComparer ModelNamesComparer { get; private set; } + + /// + /// Gets or sets the instance used to render locale values like dates and numbers. + /// + public CultureInfo CultureInfo { get; set; } = TemplateOptions.Default.CultureInfo; + + /// + /// Gets or sets the value to returned by the "now" keyword. + /// + public Func Now { get; set; } = TemplateOptions.Default.Now; + + /// + /// Gets or sets the local time zone used when parsing or creating dates without specific ones. + /// + public TimeZoneInfo TimeZone { get; set; } = TemplateOptions.Default.TimeZone; + + /// + /// Gets or sets the used by the json filter. + /// + public JsonSerializerOptions JsonSerializerOptions { get; set; } = TemplateOptions.Default.JsonSerializerOptions; + + /// + /// Increments the number of statements the current template is processing. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void IncrementSteps() + { + if (MaxSteps > 0 && _steps++ > MaxSteps) + { + ExceptionHelper.ThrowMaximumRecursionException(); + } + } + + /// + /// Gets or sets the current scope. + /// + internal Scope LocalScope { get; set; } + + /// + /// Gets or sets the root scope. + /// + internal Scope RootScope { get; set; } + + private Dictionary _ambientValues; + + /// + /// Used to define custom object on this instance to be used in filters and statements + /// but which are not available from the template. + /// + public Dictionary AmbientValues => _ambientValues ??= new Dictionary(); + + /// + /// Gets or sets a model object that is used to resolve properties in a template. This object is used if local and + /// global scopes are unsuccessful. + /// + public FluidValue Model { get; } + + /// + /// Whether the direct properties of the Model can be accessed without being registered. Default is true. + /// + public bool AllowModelMembers { get; set; } = true; + + /// + /// Gets or sets the delegate to execute when a Capture tag has been evaluated. + /// + public TemplateOptions.CapturedDelegate Captured { get; set; } + + /// + /// Gets or sets the delegate to execute when an Assign tag has been evaluated. + /// + public TemplateOptions.AssignedDelegate Assigned { get; set; } + + /// + /// Gets or sets the delegate to execute when an undefined value is used. + /// + public TemplateOptions.UndefinedDelegate Undefined { get; set; } + + /// + /// Creates a new isolated child scope. After than any value added to this content object will be released once + /// is called. The previous scope is linked such that its values are still available. + /// + public void EnterChildScope() + { + if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) + { + ExceptionHelper.ThrowMaximumRecursionException(); + return; + } + + LocalScope = new Scope(LocalScope); + } + + /// + /// Creates a new for loop scope. After than any value added to this content object will be released once + /// is called. The previous scope is linked such that its values are still available. + /// + public void EnterForLoopScope() + { + if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) + { + ExceptionHelper.ThrowMaximumRecursionException(); + return; + } + + LocalScope = new Scope(LocalScope, forLoopScope: true); + } + + /// + /// Exits the current scope that has been created by + /// + public void ReleaseScope() + { + if (_recursion > 0) + { + _recursion--; + } + + LocalScope = LocalScope.Parent; + + if (LocalScope == null) + { + ExceptionHelper.ThrowInvalidOperationException("ReleaseScope invoked without corresponding EnterChildScope"); + return; + } + } + + /// + /// Gets the names of the values. + /// + public IEnumerable ValueNames => LocalScope.Properties; + + /// + /// Gets a value from the context. + /// + /// The name of the value. + public FluidValue GetValue(string name) + { + return LocalScope.GetValue(name); + } + + /// + /// Sets a value on the context. + /// + /// The name of the value. + /// The value to set. + /// + public TemplateContext SetValue(string name, FluidValue value) + { + LocalScope.SetValue(name, value); + return this; + } + } + + public static class TemplateContextExtensions + { + public static TemplateContext SetValue(this TemplateContext context, string name, int value) + { + return context.SetValue(name, NumberValue.Create(value)); + } + + public static TemplateContext SetValue(this TemplateContext context, string name, string value) + { + return context.SetValue(name, new StringValue(value)); + } + + public static TemplateContext SetValue(this TemplateContext context, string name, char value) + { + return context.SetValue(name, StringValue.Create(value)); + } + + public static TemplateContext SetValue(this TemplateContext context, string name, bool value) + { + return context.SetValue(name, BooleanValue.Create(value)); + } + + public static TemplateContext SetValue(this TemplateContext context, string name, object value) + { + if (value == null) + { + return context.SetValue(name, NilValue.Instance); + } + + return context.SetValue(name, FluidValue.Create(value, context.Options)); + } + + public static TemplateContext SetValue(this TemplateContext context, string name, Func factory) + { + return context.SetValue(name, new FactoryValue(factory)); + } + } +} diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index 30e5007f..8c6dcc1c 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -1,140 +1,149 @@ -using Fluid.Filters; -using Fluid.Values; -using Microsoft.Extensions.FileProviders; -using System.Globalization; -using System.Text.Encodings.Web; -using System.Text.Json; - -namespace Fluid -{ - public class TemplateOptions - { - /// The name of the property that is assigned. - /// The value that is assigned. - /// The instance used for rendering the template. - /// The value which should be assigned to the property. - public delegate ValueTask AssignedDelegate(string identifier, FluidValue value, TemplateContext context); - - /// The name of the property that is assigned. - /// The value that is assigned. - /// The instance used for rendering the template. - /// The value which should be captured. - public delegate ValueTask CapturedDelegate(string identifier, FluidValue value, TemplateContext context); - - public static readonly TemplateOptions Default = new(); - - private static readonly JavaScriptEncoder DefaultJavaScriptEncoder = JavaScriptEncoder.Default; - - /// - /// Gets ot sets the members than can be accessed in a template. - /// - public MemberAccessStrategy MemberAccessStrategy { get; set; } = new DefaultMemberAccessStrategy(); - - /// - /// Gets or sets the used to access files for include and render statements. - /// - public IFileProvider FileProvider { get; set; } = new NullFileProvider(); - - /// - /// Gets or sets the used to cache templates loaded from . - /// - /// - /// The instance needs to be thread-safe for insertion and retrieval of cached entries. - /// - public ITemplateCache TemplateCache { get; set; } = new TemplateCache(); - - /// - /// Gets or sets the maximum number of steps a script can execute. Leave to 0 for unlimited. - /// - public int MaxSteps { get; set; } - - /// - /// Gets or sets the to use when comparing model names. - /// - /// - /// Default value is - /// - public StringComparer ModelNamesComparer { get; set; } = StringComparer.OrdinalIgnoreCase; - - /// - /// Gets or sets the instance used to render locale values like dates and numbers. - /// - public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; - - /// - /// Gets or sets the value returned by the "now" keyword. - /// - public Func Now { get; set; } = static () => DateTimeOffset.Now; - - /// - /// Gets or sets the local time zone used when parsing or creating dates without specific ones. - /// - public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local; - - /// - /// Gets or sets the maximum depth of recursions a script can execute. 100 by default. - /// - public int MaxRecursion { get; set; } = 100; - - /// - /// Gets the collection of filters available in the templates. - /// - public FilterCollection Filters { get; } = new FilterCollection(); - - /// - /// Gets a scope that is available in all the templates. - /// - public Scope Scope { get; } = new Scope(); - - /// - /// Gets the list of value converters. - /// - public List> ValueConverters { get; } = new List>(); - - /// - /// Gets or sets the delegate to execute when a Capture tag has been evaluated. - /// - public CapturedDelegate Captured { get; set; } - - /// - /// Gets or sets the delegate to execute when an Assign tag has been evaluated. - /// - public AssignedDelegate Assigned { get; set; } - - /// - /// Gets or sets the instance used by the json filter. - /// - [Obsolete("Use JsonSerializerOptions.Encoder instead. This property will be removed in a future version.")] - public JavaScriptEncoder JavaScriptEncoder - { - get => JsonSerializerOptions.Encoder; - set => JsonSerializerOptions.Encoder = value; - } - - /// - /// Gets or sets the used by the json filter. - /// - public JsonSerializerOptions JsonSerializerOptions { get; set; } = new JsonSerializerOptions - { - Encoder = DefaultJavaScriptEncoder - }; - - /// - /// Gets or sets the default trimming rules. - /// - public TrimmingFlags Trimming { get; set; } = TrimmingFlags.None; - - /// - /// Gets or sets whether trimming is greedy. Default is true. When set to true, all successive blank chars are trimmed. - /// - public bool Greedy { get; set; } = true; - - public TemplateOptions() - { - Filters.WithArrayFilters() - .WithStringFilters() - .WithNumberFilters() - .WithMiscFilters(); - } - } -} +using System.Globalization; +using System.Text.Encodings.Web; +using Fluid.Filters; +using Fluid.Values; +using Microsoft.Extensions.FileProviders; +using System.Text.Json; + +namespace Fluid +{ + public class TemplateOptions + { + /// The name of the property that is assigned. + /// The value that is assigned. + /// The instance used for rendering the template. + /// The value which should be assigned to the property. + public delegate ValueTask AssignedDelegate(string identifier, FluidValue value, TemplateContext context); + + /// The name of the property that is assigned. + /// The value that is assigned. + /// The instance used for rendering the template. + /// The value which should be captured. + public delegate ValueTask CapturedDelegate(string identifier, FluidValue value, TemplateContext context); + + /// The name of the value that is undefined. + /// The value to use for the undefined value. + public delegate ValueTask UndefinedDelegate(string name); + + public static readonly TemplateOptions Default = new(); + + private static readonly JavaScriptEncoder DefaultJavaScriptEncoder = JavaScriptEncoder.Default; + + /// + /// Gets ot sets the members than can be accessed in a template. + /// + public MemberAccessStrategy MemberAccessStrategy { get; set; } = new DefaultMemberAccessStrategy(); + + /// + /// Gets or sets the used to access files for include and render statements. + /// + public IFileProvider FileProvider { get; set; } = new NullFileProvider(); + + /// + /// Gets or sets the used to cache templates loaded from . + /// + /// + /// The instance needs to be thread-safe for insertion and retrieval of cached entries. + /// + public ITemplateCache TemplateCache { get; set; } = new TemplateCache(); + + /// + /// Gets or sets the maximum number of steps a script can execute. Leave to 0 for unlimited. + /// + public int MaxSteps { get; set; } + + /// + /// Gets or sets the to use when comparing model names. + /// + /// + /// Default value is + /// + public StringComparer ModelNamesComparer { get; set; } = StringComparer.OrdinalIgnoreCase; + + /// + /// Gets or sets the instance used to render locale values like dates and numbers. + /// + public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + + /// + /// Gets or sets the value returned by the "now" keyword. + /// + public Func Now { get; set; } = static () => DateTimeOffset.Now; + + /// + /// Gets or sets the local time zone used when parsing or creating dates without specific ones. + /// + public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local; + + /// + /// Gets or sets the maximum depth of recursions a script can execute. 100 by default. + /// + public int MaxRecursion { get; set; } = 100; + + /// + /// Gets the collection of filters available in the templates. + /// + public FilterCollection Filters { get; } = new FilterCollection(); + + /// + /// Gets a scope that is available in all the templates. + /// + public Scope Scope { get; } = new Scope(); + + /// + /// Gets the list of value converters. + /// + public List> ValueConverters { get; } = new List>(); + + /// + /// Gets or sets the delegate to execute when a Capture tag has been evaluated. + /// + public CapturedDelegate Captured { get; set; } + + /// + /// Gets or sets the delegate to execute when an Assign tag has been evaluated. + /// + public AssignedDelegate Assigned { get; set; } + + /// + /// Gets or sets the delegate to execute when an undefined value is encountered during rendering. + /// + public UndefinedDelegate Undefined { get; set; } + + /// + /// Gets or sets the instance used by the json filter. + /// + [Obsolete("Use JsonSerializerOptions.Encoder instead. This property will be removed in a future version.")] + public JavaScriptEncoder JavaScriptEncoder + { + get => JsonSerializerOptions.Encoder; + set => JsonSerializerOptions.Encoder = value; + } + + /// + /// Gets or sets the used by the json filter. + /// + public JsonSerializerOptions JsonSerializerOptions { get; set; } = new JsonSerializerOptions + { + Encoder = DefaultJavaScriptEncoder + }; + + /// + /// Gets or sets the default trimming rules. + /// + public TrimmingFlags Trimming { get; set; } = TrimmingFlags.None; + + /// + /// Gets or sets whether trimming is greedy. Default is true. When set to true, all successive blank chars are trimmed. + /// + public bool Greedy { get; set; } = true; + + public TemplateOptions() + { + Filters.WithArrayFilters() + .WithStringFilters() + .WithNumberFilters() + .WithMiscFilters(); + } + } +} diff --git a/Fluid/Values/EmptyValue.cs b/Fluid/Values/EmptyValue.cs index 7042a7b7..049adc09 100644 --- a/Fluid/Values/EmptyValue.cs +++ b/Fluid/Values/EmptyValue.cs @@ -5,7 +5,7 @@ namespace Fluid.Values { public sealed class EmptyValue : FluidValue { - public static readonly EmptyValue Instance = new EmptyValue(); + public static readonly EmptyValue Instance = new(); private EmptyValue() { @@ -21,6 +21,7 @@ public override bool Equals(FluidValue other) if (other == BlankValue.Instance) return true; if (other == EmptyValue.Instance) return true; if (other == NilValue.Instance) return false; + if (other == UndefinedValue.Instance) return false; return false; } diff --git a/Fluid/Values/FluidValues.cs b/Fluid/Values/FluidValues.cs index 950ac0bb..b17c22bd 100644 --- a/Fluid/Values/FluidValues.cs +++ b/Fluid/Values/FluidValues.cs @@ -4,14 +4,14 @@ public enum FluidValues { Nil = 0, Empty = 1, - Blank = 2, - Array = 3, - Boolean = 4, - Dictionary = 5, - Number = 6, - Object = 7, - String = 8, - DateTime = 9, - Function = 10, + Blank = 3, + Array = 4, + Boolean = 5, + Dictionary = 6, + Number = 7, + Object = 8, + String = 9, + DateTime = 10, + Function = 11, } } diff --git a/Fluid/Values/NilValue.cs b/Fluid/Values/NilValue.cs index 79cc9d47..4f8c5ea6 100644 --- a/Fluid/Values/NilValue.cs +++ b/Fluid/Values/NilValue.cs @@ -3,15 +3,8 @@ namespace Fluid.Values { - public sealed class NilValue : FluidValue + public abstract class BaseNilValue : FluidValue { - public static readonly NilValue Instance = new NilValue(); // a variable that is not defined, or the nil keyword - public static readonly NilValue Empty = new NilValue(); // the empty keyword - - private NilValue() - { - } - public override FluidValues Type => FluidValues.Nil; public override bool Equals(FluidValue other) @@ -73,4 +66,16 @@ public override int GetHashCode() return GetType().GetHashCode(); } } + + public sealed class NilValue : BaseNilValue + { + public static readonly NilValue Instance = new NilValue(); // a variable that is not defined, or the nil keyword + + [Obsolete("Use EmptyValue.Instance instead.")] + public static readonly EmptyValue Empty = EmptyValue.Instance; + + private NilValue() + { + } + } } diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index 3867235e..b12ca824 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -1,7 +1,8 @@ -using Fluid.Utils; -using System.Collections; +using System.Collections; using System.Globalization; +using System.Text; using System.Text.Encodings.Web; +using Fluid.Utils; namespace Fluid.Values { @@ -27,16 +28,13 @@ public override bool Equals(FluidValue other) { if (other.IsNil()) { - switch (Value) + return Value switch { - case ICollection collection: - return collection.Count == 0; + ICollection collection => collection.Count == 0, + IEnumerable enumerable => !enumerable.GetEnumerator().MoveNext(), + _ => false, + }; - case IEnumerable enumerable: - return !enumerable.GetEnumerator().MoveNext(); - } - - return false; } return other is ObjectValueBase otherObject && Value.Equals(otherObject.Value); @@ -56,7 +54,6 @@ public override ValueTask GetValueAsync(string name, TemplateContext if (name.Contains('.')) { - // Try to access the property with dots inside if (accessor != null) { if (accessor is IAsyncMemberAccessor asyncAccessor) @@ -72,24 +69,27 @@ public override ValueTask GetValueAsync(string name, TemplateContext } } - // Otherwise split the name in different segments return GetNestedValueAsync(name, context); } - else + + if (accessor != null) { - if (accessor != null) + if (accessor is IAsyncMemberAccessor asyncAccessor) { - if (accessor is IAsyncMemberAccessor asyncAccessor) - { - return Awaited(asyncAccessor, Value, name, context); - } - - return FluidValue.Create(accessor.Get(Value, name, context), context.Options); + return Awaited(asyncAccessor, Value, name, context); } + + return Create(accessor.Get(Value, name, context), context.Options); } + if (context.Undefined is not null) + { + return context.Undefined.Invoke(name); + } + return NilValue.Instance; + static async ValueTask Awaited( IAsyncMemberAccessor asyncAccessor, object value, @@ -103,11 +103,16 @@ static async ValueTask Awaited( private async ValueTask GetNestedValueAsync(string name, TemplateContext context) { var members = name.Split(MemberSeparators); - var target = Value; + List segments = context.Undefined is not null ? [] : null; foreach (var prop in members) { + if (context.Undefined is not null) + { + segments.Add(prop); + } + if (target == null) { return NilValue.Instance; @@ -117,7 +122,11 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon if (accessor == null) { - return NilValue.Instance; + if (context.Undefined is not null) + { + return await context.Undefined.Invoke(string.Join(".", segments)); + } + return UndefinedValue.Instance; } if (accessor is IAsyncMemberAccessor asyncAccessor) @@ -130,7 +139,7 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon } } - return FluidValue.Create(target, context.Options); + return Create(target, context.Options); } public override ValueTask GetIndexAsync(FluidValue index, TemplateContext context) diff --git a/Fluid/Values/UndefinedValue.cs b/Fluid/Values/UndefinedValue.cs new file mode 100644 index 00000000..3a24ad0f --- /dev/null +++ b/Fluid/Values/UndefinedValue.cs @@ -0,0 +1,11 @@ +namespace Fluid.Values +{ + public sealed class UndefinedValue : BaseNilValue + { + public static readonly UndefinedValue Instance = new(); // a variable that is not defined + + private UndefinedValue() + { + } + } +} diff --git a/README.md b/README.md index d58cc4bd..0570ae78 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ For a high-level overview, read [The Four Levels of Fluid Development](https://d - [Features](#features) - [Using Fluid in your project](#using-fluid-in-your-project) - [Allow-listing object members](#allow-listing-object-members) +- [Handling undefined variables](#handling-undefined-variables) - [Execution limits](#execution-limits) - [Converting CLR types](#converting-clr-types) - [Encoding](#encoding) @@ -238,6 +239,78 @@ options.ValueConverters.Add(o => o is Person p ? new PersonValue(p) : null); It can also be used to replace custom member access by customizing `GetValueAsync`, or do custom conversions to standard Fluid types. +
+ +## Handling undefined values + +Fluid evaluates members lazily, so undefined identifiers can be detected precisely when they are consumed. By default, undefined values render as empty strings without raising errors. + +### Tracking undefined values + +To track missing values during template rendering, assign a delegate to `TemplateOptions.Undefined` or `TemplateContext.Undefined`. This delegate is called each time an undefined variable is accessed and receives the variable path as a string parameter. + +```csharp +var missingVariables = new List(); + +var context = new TemplateContext(); +context.Undefined = name => + { + missingVariables.Add(name); + return ValueTask.FromResult(NilValue.Instance); + } +}; + +var template = FluidTemplate.Parse("Hello {{ user.name }} in {{ city }}!"); + +await template.RenderAsync(context); + +// missingVariables now contains ["user.name", "city"] +``` + +### Returning custom values for undefined values + +The `Undefined` delegate can return a custom `FluidValue` to provide fallback values or error messages for missing values: + +```csharp +var options = new TemplateOptions +{ + Undefined = name => + { + // Return a custom default value for undefined variables + return ValueTask.FromResult(new StringValue($"[{name} not found]")); + } +}; + +var template = FluidTemplate.Parse("Hello {{ user.name }} in {{ city }}!"); +var context = new TemplateContext(options); + +var result = await template.RenderAsync(context); +// Outputs: "Hello [user.name not found] in [city not found]!" +``` + +### Logging undefined accesses + +You can use the `Undefined` delegate to log missing values for debugging or monitoring: + +```csharp +var options = new TemplateOptions +{ + Undefined = path => + { + Console.WriteLine($"Missing variable: {path}"); + return ValueTask.FromResult(NilValue.Instance); + } +}; + +var template = FluidTemplate.Parse("{{ first }} {{ second }}"); +var context = new TemplateContext(options); +await template.RenderAsync(context); +// Logs: "Missing variable: first" +// Logs: "Missing variable: second" +``` + +
+ ### Inheritance All the members of the class hierarchy are registered. Besides, all inherited classes will be correctly evaluated when a base class is registered and