From 547a276154979b9afed79b28fa9f527af56ddbb6 Mon Sep 17 00:00:00 2001 From: "compyle-bot[bot]" <238113443+compyle-bot[bot]@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:05:42 +0700 Subject: [PATCH 1/9] #64: Ensure variables completeness with `StrictVariables` option --- Fluid.Tests/StrictVariableTests.cs | 372 ++++++++++++++++++ Fluid/Ast/MemberExpression.cs | 2 +- Fluid/Parser/FluidTemplate.cs | 42 +- Fluid/Scope.cs | 18 +- Fluid/StrictVariableException.cs | 43 +++ Fluid/TemplateContext.cs | 590 +++++++++++++++-------------- Fluid/TemplateOptions.cs | 259 +++++++------ Fluid/Values/ObjectValueBase.cs | 12 + 8 files changed, 923 insertions(+), 415 deletions(-) create mode 100644 Fluid.Tests/StrictVariableTests.cs create mode 100644 Fluid/StrictVariableException.cs diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs new file mode 100644 index 00000000..3c2d0f6c --- /dev/null +++ b/Fluid.Tests/StrictVariableTests.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Fluid.Parser; +using Fluid.Tests.Domain; +using Xunit; + +namespace Fluid.Tests +{ + public class StrictVariableTests + { +#if COMPILED + private static FluidParser _parser = new FluidParser().Compile(); +#else + private static FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public void StrictVariables_DefaultIsFalse() + { + // Verify TemplateOptions.StrictVariables defaults to false + var options = new TemplateOptions(); + Assert.False(options.StrictVariables); + } + + [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_ThrowsException() + { + _parser.TryParse("{{ nonExistingProperty }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("nonExistingProperty", exception.MissingVariables); + Assert.Contains("nonExistingProperty", exception.Message); + } + + [Fact] + public async Task UndefinedPropertyAccess_ThrowsException() + { + _parser.TryParse("{{ user.nonExistingProperty }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + options.MemberAccessStrategy.Register(); + var context = new TemplateContext(options); + context.SetValue("user", new Person { Firstname = "John" }); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("nonExistingProperty", exception.MissingVariables); + } + + [Fact] + public async Task MultipleMissingVariables_AllCollected() + { + _parser.TryParse("{{ var1 }} {{ var2 }} {{ var3 }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Equal(3, exception.MissingVariables.Count); + Assert.Contains("var1", exception.MissingVariables); + Assert.Contains("var2", exception.MissingVariables); + Assert.Contains("var3", exception.MissingVariables); + } + + [Fact] + public async Task NestedMissingProperties_Tracked() + { + _parser.TryParse("{{ company.Director.Firstname }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + options.MemberAccessStrategy.Register(); + // Note: Not registering Employee type + var context = new TemplateContext(options); + context.SetValue("company", new Company { Director = new Employee { Firstname = "John" } }); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Single(exception.MissingVariables); + Assert.Contains("Firstname", exception.MissingVariables); + } + + [Fact] + public async Task MixedValidAndInvalidVariables_OnlyInvalidTracked() + { + _parser.TryParse("{{ validVar }} {{ invalidVar }} {{ anotherValid }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + context.SetValue("validVar", "value1"); + context.SetValue("anotherValid", "value2"); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Single(exception.MissingVariables); + Assert.Contains("invalidVar", exception.MissingVariables); + Assert.DoesNotContain("validVar", exception.MissingVariables); + Assert.DoesNotContain("anotherValid", exception.MissingVariables); + } + + [Fact] + public async Task NoExceptionWhenAllVariablesExist() + { + _parser.TryParse("{{ name }} {{ age }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + context.SetValue("name", "John"); + context.SetValue("age", 25); + + var result = await template.RenderAsync(context); + Assert.Equal("John 25", result); + } + + [Fact] + public async Task StrictVariables_InIfConditions() + { + _parser.TryParse("{% if undefinedVar %}yes{% else %}no{% endif %}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedVar", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_InForLoops() + { + _parser.TryParse("{% for item in undefinedCollection %}{{ item }}{% endfor %}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedCollection", exception.MissingVariables); + } + + [Fact] + public async Task DuplicateMissingVariables_ListedOnce() + { + _parser.TryParse("{{ missing }} {{ missing }} {{ missing }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Single(exception.MissingVariables); + Assert.Contains("missing", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_WithModelFallback() + { + _parser.TryParse("{{ existingModelProp }} {{ nonExistentModelProp }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var model = new { existingModelProp = "value" }; + var context = new TemplateContext(model, options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("nonExistentModelProp", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_WithFilters() + { + _parser.TryParse("{{ undefinedVar | upcase }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedVar", exception.MissingVariables); + } + + [Fact] + public async Task ExceptionMessageFormat_IsCorrect() + { + _parser.TryParse("{{ var1 }} {{ var2 }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.StartsWith("The following variables were not found:", exception.Message); + Assert.Contains("var1", exception.Message); + Assert.Contains("var2", exception.Message); + } + + [Fact] + public async Task RegisteredProperties_DontThrow() + { + _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + 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); + } + + [Fact] + public async Task StrictVariables_WithAssignment() + { + _parser.TryParse("{% assign x = undefinedVar %}{{ x }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedVar", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_WithCase() + { + _parser.TryParse("{% case undefinedVar %}{% when 1 %}one{% endcase %}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedVar", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_EmptyStringNotMissing() + { + _parser.TryParse("{{ emptyString }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + context.SetValue("emptyString", ""); + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + } + + [Fact] + public async Task StrictVariables_NullValueNotMissing() + { + _parser.TryParse("{{ nullValue }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + context.SetValue("nullValue", (object)null); + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + } + + [Fact] + public async Task StrictVariables_WithBinaryExpression() + { + _parser.TryParse("{% if undefinedVar > 5 %}yes{% endif %}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefinedVar", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_MultipleRenders_ClearsTracking() + { + _parser.TryParse("{{ missing }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + // First render should throw + await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + + // Now set the variable + context.SetValue("missing", "value"); + + // Second render should succeed + var result = await template.RenderAsync(context); + Assert.Equal("value", result); + } + + [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 = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var products = new[] + { + new { name = "Product 1", price = 10 }, + new { name = "Product 2", price = 20 } + }; + context.SetValue("products", products); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("stock", exception.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 = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("undefined", exception.MissingVariables); + } + + [Fact] + public async Task StrictVariables_NoOutputWhenException() + { + _parser.TryParse("Start {{ missing }} End", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + // Should throw exception and produce no output + await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + } + + [Fact] + public async Task StrictVariables_WithRange() + { + _parser.TryParse("{% for i in (1..5) %}{{ i }}{% endfor %}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + // Should work fine - no missing variables + var result = await template.RenderAsync(context); + Assert.Equal("12345", result); + } + + [Fact] + public async Task StrictVariables_WithCapture() + { + _parser.TryParse("{% capture foo %}{{ bar }}{% endcapture %}{{ foo }}", out var template, out var _); + + var options = new TemplateOptions { StrictVariables = true }; + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Contains("bar", exception.MissingVariables); + } + } +} diff --git a/Fluid/Ast/MemberExpression.cs b/Fluid/Ast/MemberExpression.cs index c5e1346b..9fbdc7f7 100644 --- a/Fluid/Ast/MemberExpression.cs +++ b/Fluid/Ast/MemberExpression.cs @@ -34,7 +34,7 @@ public override ValueTask EvaluateAsync(TemplateContext context) // Search the initial segment in the local scope first - var value = context.LocalScope.GetValue(initial.Identifier); + var value = context.LocalScope.GetValue(initial.Identifier, context); // If it was not successful, try again with a member of the model diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index 7d9b5981..294d6a70 100644 --- a/Fluid/Parser/FluidTemplate.cs +++ b/Fluid/Parser/FluidTemplate.cs @@ -34,15 +34,26 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon ExceptionHelper.ThrowArgumentNullException(nameof(context)); } + // Clear missing variables from previous renders + context.ClearMissingVariables(); + + // If StrictVariables enabled, render to temp buffer to collect all missing variables + TextWriter targetWriter = writer; + if (context.Options.StrictVariables) + { + targetWriter = new StringWriter(); + } + var count = Statements.Count; for (var i = 0; i < count; i++) { - var task = Statements[i].WriteToAsync(writer, encoder, context); + var task = Statements[i].WriteToAsync(targetWriter, encoder, context); if (!task.IsCompletedSuccessfully) { return Awaited( task, writer, + targetWriter, encoder, context, Statements, @@ -50,12 +61,26 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon } } + // Check for missing variables after rendering + if (context.Options.StrictVariables) + { + var missingVariables = context.GetMissingVariables(); + if (missingVariables.Count > 0) + { + throw new StrictVariableException(missingVariables); + } + + // Write buffered output to actual writer + writer.Write(((StringWriter)targetWriter).ToString()); + } + return new ValueTask(); } private static async ValueTask Awaited( ValueTask task, TextWriter writer, + TextWriter targetWriter, TextEncoder encoder, TemplateContext context, IReadOnlyList statements, @@ -64,7 +89,20 @@ private static async ValueTask Awaited( await task; for (var i = startIndex; i < statements.Count; i++) { - await statements[i].WriteToAsync(writer, encoder, context); + await statements[i].WriteToAsync(targetWriter, encoder, context); + } + + // Check for missing variables after async rendering + if (context.Options.StrictVariables) + { + var missingVariables = context.GetMissingVariables(); + if (missingVariables.Count > 0) + { + throw new StrictVariableException(missingVariables); + } + + // Write buffered output to actual writer + await writer.WriteAsync(((StringWriter)targetWriter).ToString()); } } } diff --git a/Fluid/Scope.cs b/Fluid/Scope.cs index 97d2ce81..7fbc121a 100644 --- a/Fluid/Scope.cs +++ b/Fluid/Scope.cs @@ -46,8 +46,9 @@ public Scope(Scope parent, bool forLoopScope, StringComparer stringComparer = nu /// if it doesn't exist. /// /// The name of the value to return. + /// The optional template context for tracking missing variables. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public FluidValue GetValue(string name) + public FluidValue GetValue(string name, TemplateContext context = null) { if (name == null) { @@ -59,9 +60,18 @@ public FluidValue GetValue(string name) return result; } - return Parent != null - ? Parent.GetValue(name) - : NilValue.Instance; + if (Parent != null) + { + return Parent.GetValue(name, context); + } + + // Track missing variable if StrictVariables enabled + if (context?.Options.StrictVariables == true) + { + context.TrackMissingVariable(name); + } + + return NilValue.Instance; } /// diff --git a/Fluid/StrictVariableException.cs b/Fluid/StrictVariableException.cs new file mode 100644 index 00000000..ff2477ce --- /dev/null +++ b/Fluid/StrictVariableException.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace Fluid +{ + /// + /// Exception thrown when StrictVariables is enabled and undefined template variables are accessed. + /// + public class StrictVariableException : InvalidOperationException + { + /// + /// Initializes a new instance of the class. + /// + /// The list of missing variable paths. + public StrictVariableException(IReadOnlyList missingVariables) + : base() + { + MissingVariables = missingVariables ?? throw new ArgumentNullException(nameof(missingVariables)); + } + + /// + /// Initializes a new instance of the class. + /// + /// The list of missing variable paths. + /// The exception that is the cause of the current exception. + public StrictVariableException(IReadOnlyList missingVariables, Exception innerException) + : base(null, innerException) + { + MissingVariables = missingVariables ?? throw new ArgumentNullException(nameof(missingVariables)); + } + + /// + /// Gets the collection of missing variable paths. + /// + public IReadOnlyList MissingVariables { get; } + + /// + /// Gets a message that describes the current exception. + /// + public override string Message => + $"The following variables were not found: {string.Join(", ", MissingVariables)}"; + } +} diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index dd7d0e32..f266264f 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -1,282 +1,308 @@ -using Fluid.Values; -using System.Globalization; -using System.Runtime.CompilerServices; - -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; - } - - /// - /// 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; - - /// - /// 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 Fluid.Values; +using System.Globalization; +using System.Runtime.CompilerServices; + +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; + } + + /// + /// 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; + + /// + /// 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; + private HashSet _missingVariables; + + /// + /// 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(); + + /// + /// Tracks a missing variable when StrictVariables is enabled. + /// + internal void TrackMissingVariable(string variablePath) + { + _missingVariables ??= new HashSet(); + _missingVariables.Add(variablePath); + } + + /// + /// Gets the collection of missing variables tracked during rendering. + /// + internal IReadOnlyList GetMissingVariables() + { + return _missingVariables?.ToList() ?? new List(); + } + + /// + /// Clears the collection of missing variables. + /// + internal void ClearMissingVariables() + { + _missingVariables?.Clear(); + } + + /// + /// 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, this); + } + + /// + /// 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 74d2dfa0..ccd82696 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -1,126 +1,133 @@ -using Fluid.Filters; -using Fluid.Values; -using Microsoft.Extensions.FileProviders; -using System.Globalization; -using System.Text.Encodings.Web; - -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. - /// - public JavaScriptEncoder JavaScriptEncoder { get; set; } = 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 Fluid.Filters; +using Fluid.Values; +using Microsoft.Extensions.FileProviders; +using System.Globalization; +using System.Text.Encodings.Web; + +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. + /// + public JavaScriptEncoder JavaScriptEncoder { get; set; } = 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; + + /// + /// Gets or sets whether to throw an exception when a template variable is not found. + /// When true, accessing undefined variables will collect all missing variables and throw a StrictVariableException. + /// Default is false. + /// + public bool StrictVariables { get; set; } = false; + + public TemplateOptions() + { + Filters.WithArrayFilters() + .WithStringFilters() + .WithNumberFilters() + .WithMiscFilters(); + } + } +} diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index 84c8c2d2..9308327c 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -88,6 +88,12 @@ public override ValueTask GetValueAsync(string name, TemplateContext } } + // Track missing property if StrictVariables enabled + if (context.Options.StrictVariables) + { + context.TrackMissingVariable(name); + } + return new ValueTask(NilValue.Instance); @@ -118,6 +124,12 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon if (accessor == null) { + // Track missing nested property if StrictVariables enabled + if (context.Options.StrictVariables) + { + context.TrackMissingVariable(prop); + } + return NilValue.Instance; } From 97015994b547c2a0c10122b8abc4bd5f5dcf7011 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Mon, 27 Oct 2025 00:05:42 +0500 Subject: [PATCH 2/9] #64: Replace strict variable buffering with UndefinedValue sentinel --- Fluid.Tests/StrictVariableTests.cs | 49 ++++++++++- Fluid/Ast/MemberExpression.cs | 10 +-- Fluid/Parser/FluidTemplate.cs | 43 +++------- Fluid/Scope.cs | 36 +++++--- Fluid/TemplateContext.cs | 49 +++++++---- Fluid/TemplateOptions.cs | 15 +++- Fluid/UndefinedVariableEventArgs.cs | 30 +++++++ Fluid/Values/ObjectValueBase.cs | 50 +++++------ Fluid/Values/UndefinedValue.cs | 126 ++++++++++++++++++++++++++++ README.md | 59 +++++++++++++ 10 files changed, 368 insertions(+), 99 deletions(-) create mode 100644 Fluid/UndefinedVariableEventArgs.cs create mode 100644 Fluid/Values/UndefinedValue.cs diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs index 3c2d0f6c..718b0a26 100644 --- a/Fluid.Tests/StrictVariableTests.cs +++ b/Fluid.Tests/StrictVariableTests.cs @@ -1,7 +1,5 @@ -using System; using System.Collections.Generic; using System.Threading.Tasks; -using Fluid.Parser; using Fluid.Tests.Domain; using Xunit; @@ -368,5 +366,52 @@ public async Task StrictVariables_WithCapture() var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); Assert.Contains("bar", exception.MissingVariables); } + + [Fact] + public async Task UndefinedDelegate_ReceivesNotifications() + { + _parser.TryParse("{{ first }} {{ first }} {{ second }}", out var template, out var _); + + var arguments = new List(); + var options = new TemplateOptions + { + Undefined = args => arguments.Add(args) + }; + + var context = new TemplateContext(options); + + var result = await template.RenderAsync(context); + Assert.True(string.IsNullOrWhiteSpace(result)); + + Assert.Equal(3, arguments.Count); + Assert.Equal("first", arguments[0].Path); + Assert.True(arguments[0].IsFirstOccurrence); + Assert.Equal("first", arguments[1].Path); + Assert.False(arguments[1].IsFirstOccurrence); + Assert.Equal("second", arguments[2].Path); + Assert.True(arguments[2].IsFirstOccurrence); + } + + [Fact] + public async Task StrictVariables_ThrowsAfterUndefinedDelegate() + { + _parser.TryParse("{{ missing }}", out var template, out var _); + + var paths = new List(); + var options = new TemplateOptions + { + StrictVariables = true, + Undefined = args => paths.Add(args.Path) + }; + + var context = new TemplateContext(options); + + var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + Assert.Single(exception.MissingVariables); + Assert.Equal("missing", exception.MissingVariables[0]); + + Assert.Single(paths); + Assert.Equal("missing", paths[0]); + } } } diff --git a/Fluid/Ast/MemberExpression.cs b/Fluid/Ast/MemberExpression.cs index 9fbdc7f7..10f5ae9b 100644 --- a/Fluid/Ast/MemberExpression.cs +++ b/Fluid/Ast/MemberExpression.cs @@ -32,19 +32,13 @@ public override ValueTask EvaluateAsync(TemplateContext context) var initial = _segments[0] as IdentifierSegment; - // Search the initial segment in the local scope first - - var value = context.LocalScope.GetValue(initial.Identifier, context); - - // If it was not successful, try again with a member of the model - var start = 1; - if (value.IsNil()) + if (!context.LocalScope.TryGetValue(initial.Identifier, out var value)) { if (context.Model == null) { - return new ValueTask(value); + return new ValueTask(context.CreateUndefinedValue(initial.Identifier)); } start = 0; diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index 294d6a70..575926fb 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 { @@ -34,26 +34,20 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon ExceptionHelper.ThrowArgumentNullException(nameof(context)); } - // Clear missing variables from previous renders - context.ClearMissingVariables(); - - // If StrictVariables enabled, render to temp buffer to collect all missing variables - TextWriter targetWriter = writer; - if (context.Options.StrictVariables) + if (context.ShouldTrackUndefined) { - targetWriter = new StringWriter(); + context.ClearMissingVariables(); } var count = Statements.Count; for (var i = 0; i < count; i++) { - var task = Statements[i].WriteToAsync(targetWriter, encoder, context); + var task = Statements[i].WriteToAsync(writer, encoder, context); if (!task.IsCompletedSuccessfully) { return Awaited( task, writer, - targetWriter, encoder, context, Statements, @@ -61,17 +55,9 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon } } - // Check for missing variables after rendering - if (context.Options.StrictVariables) + if (context.Options.StrictVariables && context.HasMissingVariables) { - var missingVariables = context.GetMissingVariables(); - if (missingVariables.Count > 0) - { - throw new StrictVariableException(missingVariables); - } - - // Write buffered output to actual writer - writer.Write(((StringWriter)targetWriter).ToString()); + throw new StrictVariableException(context.GetMissingVariables()); } return new ValueTask(); @@ -80,7 +66,6 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon private static async ValueTask Awaited( ValueTask task, TextWriter writer, - TextWriter targetWriter, TextEncoder encoder, TemplateContext context, IReadOnlyList statements, @@ -89,20 +74,12 @@ private static async ValueTask Awaited( await task; for (var i = startIndex; i < statements.Count; i++) { - await statements[i].WriteToAsync(targetWriter, encoder, context); + await statements[i].WriteToAsync(writer, encoder, context); } - // Check for missing variables after async rendering - if (context.Options.StrictVariables) + if (context.Options.StrictVariables && context.HasMissingVariables) { - var missingVariables = context.GetMissingVariables(); - if (missingVariables.Count > 0) - { - throw new StrictVariableException(missingVariables); - } - - // Write buffered output to actual writer - await writer.WriteAsync(((StringWriter)targetWriter).ToString()); + throw new StrictVariableException(context.GetMissingVariables()); } } } diff --git a/Fluid/Scope.cs b/Fluid/Scope.cs index 7fbc121a..e6cc3a77 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 { @@ -46,9 +46,25 @@ public Scope(Scope parent, bool forLoopScope, StringComparer stringComparer = nu /// if it doesn't exist. /// /// The name of the value to return. - /// The optional template context for tracking missing variables. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public FluidValue GetValue(string name, TemplateContext context = null) + public FluidValue GetValue(string name) + { + if (TryGetValue(name, out var value)) + { + return value; + } + + return NilValue.Instance; + } + + /// + /// Attempts to retrieve the value with the specified name in the chain of scopes. + /// + /// The name of the value to return. + /// When this method returns, contains the value associated with the specified name, if found; otherwise . + /// true if the value was found; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetValue(string name, out FluidValue value) { if (name == null) { @@ -57,21 +73,17 @@ public FluidValue GetValue(string name, TemplateContext context = null) if (_properties != null && _properties.TryGetValue(name, out var result)) { - return result; + value = result; + return true; } if (Parent != null) { - return Parent.GetValue(name, context); + return Parent.TryGetValue(name, out value); } - // Track missing variable if StrictVariables enabled - if (context?.Options.StrictVariables == true) - { - context.TrackMissingVariable(name); - } - - return NilValue.Instance; + value = NilValue.Instance; + return false; } /// diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index f266264f..208e25c1 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -1,6 +1,6 @@ -using Fluid.Values; using System.Globalization; using System.Runtime.CompilerServices; +using Fluid.Values; namespace Fluid { @@ -146,26 +146,42 @@ public void IncrementSteps() /// public Dictionary AmbientValues => _ambientValues ??= new Dictionary(); - /// - /// Tracks a missing variable when StrictVariables is enabled. - /// - internal void TrackMissingVariable(string variablePath) + internal bool ShouldTrackUndefined => Options.StrictVariables || Options.Undefined != null; + + internal FluidValue CreateUndefinedValue(string path) { - _missingVariables ??= new HashSet(); - _missingVariables.Add(variablePath); + if (!ShouldTrackUndefined) + { + return NilValue.Instance; + } + + return new UndefinedValue(path, this); + } + + internal void NotifyUndefinedUsage(string path) + { + if (!ShouldTrackUndefined) + { + return; + } + + _missingVariables ??= new HashSet(StringComparer.Ordinal); + var isFirst = _missingVariables.Add(path); + + var handler = Options.Undefined; + if (handler != null) + { + handler(new UndefinedVariableEventArgs(this, path, isFirst)); + } } - /// - /// Gets the collection of missing variables tracked during rendering. - /// internal IReadOnlyList GetMissingVariables() { return _missingVariables?.ToList() ?? new List(); } - /// - /// Clears the collection of missing variables. - /// + internal bool HasMissingVariables => _missingVariables != null && _missingVariables.Count > 0; + internal void ClearMissingVariables() { _missingVariables?.Clear(); @@ -252,7 +268,12 @@ public void ReleaseScope() /// The name of the value. public FluidValue GetValue(string name) { - return LocalScope.GetValue(name, this); + if (LocalScope.TryGetValue(name, out var value)) + { + return value; + } + + return CreateUndefinedValue(name); } /// diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index ccd82696..d92703f2 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -1,8 +1,8 @@ +using System.Globalization; +using System.Text.Encodings.Web; using Fluid.Filters; using Fluid.Values; using Microsoft.Extensions.FileProviders; -using System.Globalization; -using System.Text.Encodings.Web; namespace Fluid { @@ -20,6 +20,12 @@ public class TemplateOptions /// The value which should be captured. public delegate ValueTask CapturedDelegate(string identifier, FluidValue value, TemplateContext context); + /// + /// Represents the method that will handle undefined variable notifications. + /// + /// The event arguments containing context information. + public delegate void UndefinedVariableDelegate(UndefinedVariableEventArgs args); + public static readonly TemplateOptions Default = new(); private static readonly JavaScriptEncoder DefaultJavaScriptEncoder = JavaScriptEncoder.Default; @@ -100,6 +106,11 @@ public class TemplateOptions /// public AssignedDelegate Assigned { get; set; } + /// + /// Gets or sets the delegate to execute when an undefined value is encountered during rendering. + /// + public UndefinedVariableDelegate Undefined { get; set; } + /// /// Gets or sets the instance used by the json filter. /// diff --git a/Fluid/UndefinedVariableEventArgs.cs b/Fluid/UndefinedVariableEventArgs.cs new file mode 100644 index 00000000..6d8a01cb --- /dev/null +++ b/Fluid/UndefinedVariableEventArgs.cs @@ -0,0 +1,30 @@ +namespace Fluid +{ + /// + /// Provides data for undefined variable notifications raised by . + /// + public sealed class UndefinedVariableEventArgs + { + internal UndefinedVariableEventArgs(TemplateContext context, string path, bool isFirstOccurrence) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Path = path ?? throw new ArgumentNullException(nameof(path)); + IsFirstOccurrence = isFirstOccurrence; + } + + /// + /// Gets the in which the undefined variable was encountered. + /// + public TemplateContext Context { get; } + + /// + /// Gets the path that was evaluated and resolved to an undefined value. + /// + public string Path { get; } + + /// + /// Gets a value indicating whether this is the first time this undefined path was observed during the render. + /// + public bool IsFirstOccurrence { get; } + } +} diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index 9308327c..0d865dff 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 { @@ -56,7 +57,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) @@ -68,33 +68,24 @@ public override ValueTask GetValueAsync(string name, TemplateContext if (directValue != null) { - return new ValueTask(FluidValue.Create(directValue, context.Options)); + return new ValueTask(Create(directValue, context.Options)); } } - // 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); } - } - // Track missing property if StrictVariables enabled - if (context.Options.StrictVariables) - { - context.TrackMissingVariable(name); + return Create(accessor.Get(Value, name, context), context.Options); } - return new ValueTask(NilValue.Instance); + return new ValueTask(context.CreateUndefinedValue(name)); static async ValueTask Awaited( @@ -110,11 +101,20 @@ static async ValueTask Awaited( private async ValueTask GetNestedValueAsync(string name, TemplateContext context) { var members = name.Split(MemberSeparators); - var target = Value; + var builder = new StringBuilder(); + var first = true; foreach (var prop in members) { + if (!first) + { + builder.Append('.'); + } + + builder.Append(prop); + first = false; + if (target == null) { return NilValue.Instance; @@ -124,13 +124,7 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon if (accessor == null) { - // Track missing nested property if StrictVariables enabled - if (context.Options.StrictVariables) - { - context.TrackMissingVariable(prop); - } - - return NilValue.Instance; + return context.CreateUndefinedValue(builder.ToString()); } if (accessor is IAsyncMemberAccessor asyncAccessor) @@ -143,7 +137,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..ab95f741 --- /dev/null +++ b/Fluid/Values/UndefinedValue.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using System.Text.Encodings.Web; + +namespace Fluid.Values +{ + internal sealed class UndefinedValue : FluidValue + { + private readonly TemplateContext _context; + private readonly string _path; + private bool _notified; + + public UndefinedValue(string path, TemplateContext context) + { + _path = path ?? throw new ArgumentNullException(nameof(path)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public string Path => _path; + + private void NotifyUsage() + { + if (_notified) + { + return; + } + + _notified = true; + _context.NotifyUndefinedUsage(_path); + } + + internal UndefinedValue AppendProperty(string property) + { + if (string.IsNullOrEmpty(property)) + { + return this; + } + + return new UndefinedValue($"{_path}.{property}", _context); + } + + internal UndefinedValue AppendIndexer(string index) + { + if (string.IsNullOrEmpty(index)) + { + return this; + } + + return new UndefinedValue($"{_path}[{index}]", _context); + } + + public override bool Equals(FluidValue other) + { + NotifyUsage(); + return other == NilValue.Instance || other.IsNil(); + } + + public override bool ToBooleanValue() + { + NotifyUsage(); + return false; + } + + public override decimal ToNumberValue() + { + NotifyUsage(); + return 0; + } + + public override string ToStringValue() + { + NotifyUsage(); + return string.Empty; + } + + public override object ToObjectValue() + { + NotifyUsage(); + return null; + } + + public override ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) + { + AssertWriteToParameters(writer, encoder, cultureInfo); + NotifyUsage(); + return default; + } + + [Obsolete("WriteTo is obsolete, prefer the WriteToAsync method.")] + public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) + { + AssertWriteToParameters(writer, encoder, cultureInfo); + NotifyUsage(); + } + + public override ValueTask GetValueAsync(string name, TemplateContext context) + { + return new ValueTask(AppendProperty(name)); + } + + public override ValueTask GetIndexAsync(FluidValue index, TemplateContext context) + { + var value = index.ToStringValue(); + return new ValueTask(AppendIndexer(value)); + } + + public override ValueTask InvokeAsync(FunctionArguments arguments, TemplateContext context) + { + NotifyUsage(); + return new ValueTask(NilValue.Instance); + } + + public override FluidValues Type => FluidValues.Nil; + + public override bool IsNil() + { + NotifyUsage(); + return true; + } + + public override IEnumerable Enumerate(TemplateContext context) + { + NotifyUsage(); + return Array.Empty(); + } + } +} diff --git a/README.md b/README.md index 379d2e17..8d28ee42 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,64 @@ 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 variables + +Fluid evaluates members lazily, so undefined identifiers can be detected precisely when they are consumed. You can opt into strict validation or simply observe the accesses. + +### Strict enforcement + +Enable `TemplateOptions.StrictVariables` to aggregate every missing variable that is evaluated while rendering. Fluid throws a `StrictVariableException` after the template finishes, and the exception exposes the full list via `MissingVariables`. + +```csharp +var options = new TemplateOptions +{ + StrictVariables = true +}; + +var template = FluidTemplate.Parse("Hello {{ user.name }} in {{ city }}!"); +var context = new TemplateContext(options); + +try +{ + await template.RenderAsync(context); +} +catch (StrictVariableException ex) +{ + // ex.MissingVariables contains ["user.name", "city"] +} +``` + +### Observing undefined accesses + +To log, collect metrics, or apply custom policies, assign `TemplateOptions.Undefined`. The delegate receives an `UndefinedVariableEventArgs` instance each time a path resolves to an undefined value, including: + +- `Path`: the path that was evaluated (nested identifiers are dot-separated). +- `Context`: the active `TemplateContext`. +- `IsFirstOccurrence`: `true` only the first time that path is seen during the render. + +```csharp +var options = new TemplateOptions +{ + Undefined = args => + { + if (args.IsFirstOccurrence) + { + Console.WriteLine($"Missing variable: {args.Path}"); + } + } +}; + +var template = FluidTemplate.Parse("{{ first }} {{ first }} {{ second }}"); +var context = new TemplateContext(options); +await template.RenderAsync(context); +``` + +The callback runs regardless of the strict-variable setting. When strict mode is enabled and a handler is provided, the notifications are raised before `StrictVariableException` is thrown, allowing you to fail fast or defer the decision. + +
+ ### 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 From 3498f150e9e3f0bec2004aad82e059234d36fb98 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Sat, 1 Nov 2025 14:50:42 -0700 Subject: [PATCH 3/9] Refactor to use Nil --- Fluid.Tests/StrictVariableTests.cs | 712 +++++++++++++++------------- Fluid/Ast/MemberExpression.cs | 16 +- Fluid/Parser/FluidTemplate.cs | 18 +- Fluid/Scope.cs | 30 +- Fluid/StrictVariableException.cs | 43 -- Fluid/TemplateContext.cs | 57 +-- Fluid/TemplateOptions.cs | 12 +- Fluid/UndefinedVariableEventArgs.cs | 30 -- Fluid/Values/ObjectValueBase.cs | 37 +- Fluid/Values/UndefinedValue.cs | 126 ----- 10 files changed, 450 insertions(+), 631 deletions(-) delete mode 100644 Fluid/StrictVariableException.cs delete mode 100644 Fluid/UndefinedVariableEventArgs.cs delete mode 100644 Fluid/Values/UndefinedValue.cs diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs index 718b0a26..0760d102 100644 --- a/Fluid.Tests/StrictVariableTests.cs +++ b/Fluid.Tests/StrictVariableTests.cs @@ -1,417 +1,495 @@ using System.Collections.Generic; using System.Threading.Tasks; using Fluid.Tests.Domain; +using Fluid.Values; using Xunit; -namespace Fluid.Tests +namespace Fluid.Tests; + +public class StrictVariableTests { - public class StrictVariableTests - { #if COMPILED - private static FluidParser _parser = new FluidParser().Compile(); + private static FluidParser _parser = new FluidParser().Compile(); #else - private static FluidParser _parser = new FluidParser(); + private static FluidParser _parser = new FluidParser(); #endif - [Fact] - public void StrictVariables_DefaultIsFalse() - { - // Verify TemplateOptions.StrictVariables defaults to false - var options = new TemplateOptions(); - Assert.False(options.StrictVariables); - } + [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 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_ThrowsException() + [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) => { - _parser.TryParse("{{ nonExistingProperty }}", out var template, out var _); + Assert.Equal("nonExistingProperty", path); + detected = true; + return ValueTask.FromResult(NilValue.Instance); + }; + + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.True(detected); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task UndefinedPropertyAccess_TracksMissingVariable() + { + _parser.TryParse("{{ user.nonExistingProperty }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("nonExistingProperty", exception.MissingVariables); - Assert.Contains("nonExistingProperty", exception.Message); - } + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + var context = new TemplateContext(options); + context.SetValue("user", new Person { Firstname = "John" }); - [Fact] - public async Task UndefinedPropertyAccess_ThrowsException() - { - _parser.TryParse("{{ user.nonExistingProperty }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("nonExistingProperty", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - options.MemberAccessStrategy.Register(); - var context = new TemplateContext(options); - context.SetValue("user", new Person { Firstname = "John" }); + [Fact] + public async Task MultipleMissingVariables_AllCollected() + { + _parser.TryParse("{{ var1 }} {{ var2 }} {{ var3 }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("nonExistingProperty", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task MultipleMissingVariables_AllCollected() - { - _parser.TryParse("{{ var1 }} {{ var2 }} {{ var3 }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Equal(3, missingVariables.Count); + Assert.Contains("var1", missingVariables); + Assert.Contains("var2", missingVariables); + Assert.Contains("var3", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task MissingSubProperties_Tracked() + { + _parser.TryParse("{{ company.Director.Firstname }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Equal(3, exception.MissingVariables.Count); - Assert.Contains("var1", exception.MissingVariables); - Assert.Contains("var2", exception.MissingVariables); - Assert.Contains("var3", exception.MissingVariables); - } + 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" } }); - [Fact] - public async Task NestedMissingProperties_Tracked() - { - _parser.TryParse("{{ company.Director.Firstname }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Single(missingVariables); + Assert.Contains("Firstname", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - options.MemberAccessStrategy.Register(); - // Note: Not registering Employee type - var context = new TemplateContext(options); - context.SetValue("company", new Company { Director = new Employee { Firstname = "John" } }); + [Fact] + public async Task NestedMissingProperties_Tracked() + { + _parser.TryParse("{{ company['Director.Firstname'] }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Single(exception.MissingVariables); - Assert.Contains("Firstname", exception.MissingVariables); - } + 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" } }); - [Fact] - public async Task MixedValidAndInvalidVariables_OnlyInvalidTracked() - { - _parser.TryParse("{{ validVar }} {{ invalidVar }} {{ anotherValid }}", out var template, out var _); - - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); - context.SetValue("validVar", "value1"); - context.SetValue("anotherValid", "value2"); - - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Single(exception.MissingVariables); - Assert.Contains("invalidVar", exception.MissingVariables); - Assert.DoesNotContain("validVar", exception.MissingVariables); - Assert.DoesNotContain("anotherValid", exception.MissingVariables); - } - - [Fact] - public async Task NoExceptionWhenAllVariablesExist() - { - _parser.TryParse("{{ name }} {{ age }}", out var template, out var _); + 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); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); - context.SetValue("name", "John"); - context.SetValue("age", 25); + [Fact] + public async Task NoExceptionWhenAllVariablesExist() + { + _parser.TryParse("{{ name }} {{ age }}", out var template, out var _); - var result = await template.RenderAsync(context); - Assert.Equal("John 25", result); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("name", "John"); + context.SetValue("age", 25); - [Fact] - public async Task StrictVariables_InIfConditions() - { - _parser.TryParse("{% if undefinedVar %}yes{% else %}no{% endif %}", out var template, out var _); + var result = await template.RenderAsync(context); + Assert.Equal("John 25", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_InIfConditions() + { + _parser.TryParse("{% if undefinedVar %}yes{% else %}no{% endif %}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedVar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_InForLoops() - { - _parser.TryParse("{% for item in undefinedCollection %}{{ item }}{% endfor %}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_InForLoops() + { + _parser.TryParse("{% for item in undefinedCollection %}{{ item }}{% endfor %}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedCollection", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task DuplicateMissingVariables_ListedOnce() - { - _parser.TryParse("{{ missing }} {{ missing }} {{ missing }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("undefinedCollection", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task DuplicateMissingVariables_ListedAsManyTimes() + { + _parser.TryParse("{{ missing }} {{ missing }} {{ missing }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Single(exception.MissingVariables); - Assert.Contains("missing", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_WithModelFallback() - { - _parser.TryParse("{{ existingModelProp }} {{ nonExistentModelProp }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Equal(3, missingVariables.Count); + Assert.All(missingVariables, item => Assert.Equal("missing", item)); + } - var options = new TemplateOptions { StrictVariables = true }; - var model = new { existingModelProp = "value" }; - var context = new TemplateContext(model, options); + [Fact] + public async Task StrictVariables_WithModelFallback() + { + _parser.TryParse("{{ existingModelProp }} {{ nonExistentModelProp }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("nonExistentModelProp", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var model = new { existingModelProp = "value" }; + var context = new TemplateContext(model, options); - [Fact] - public async Task StrictVariables_WithFilters() - { - _parser.TryParse("{{ undefinedVar | upcase }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("nonExistentModelProp", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithFilters() + { + _parser.TryParse("{{ undefinedVar | upcase }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedVar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task ExceptionMessageFormat_IsCorrect() - { - _parser.TryParse("{{ var1 }} {{ var2 }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task MissingVariablesFormat_IsCorrect() + { + _parser.TryParse("{{ var1 }} {{ var2 }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.StartsWith("The following variables were not found:", exception.Message); - Assert.Contains("var1", exception.Message); - Assert.Contains("var2", exception.Message); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task RegisteredProperties_DontThrow() - { - _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("var1", missingVariables); + Assert.Contains("var2", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - options.MemberAccessStrategy.Register(); - var context = new TemplateContext(options); - context.SetValue("person", new Person { Firstname = "John", Lastname = "Doe" }); + [Fact] + public async Task RegisteredProperties_DontThrow() + { + _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); - var result = await template.RenderAsync(context); - Assert.Equal("John Doe", result); - } + var (options, missingVariables) = CreateStrictOptions(); + options.MemberAccessStrategy.Register(); + var context = new TemplateContext(options); + context.SetValue("person", new Person { Firstname = "John", Lastname = "Doe" }); - [Fact] - public async Task StrictVariables_WithAssignment() - { - _parser.TryParse("{% assign x = undefinedVar %}{{ x }}", out var template, out var _); + var result = await template.RenderAsync(context); + Assert.Equal("John Doe", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithAssignment() + { + _parser.TryParse("{% assign x = undefinedVar %}{{ x }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedVar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_WithCase() - { - _parser.TryParse("{% case undefinedVar %}{% when 1 %}one{% endcase %}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithCase() + { + _parser.TryParse("{% case undefinedVar %}{% when 1 %}one{% endcase %}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedVar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_EmptyStringNotMissing() - { - _parser.TryParse("{{ emptyString }}", out var template, out var _); + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); - context.SetValue("emptyString", ""); + [Fact] + public async Task StrictVariables_EmptyStringNotMissing() + { + _parser.TryParse("{{ emptyString }}", out var template, out var _); - var result = await template.RenderAsync(context); - Assert.Equal("", result); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("emptyString", ""); - [Fact] - public async Task StrictVariables_NullValueNotMissing() - { - _parser.TryParse("{{ nullValue }}", out var template, out var _); + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); - context.SetValue("nullValue", (object)null); + [Fact] + public async Task StrictVariables_NullValueNotMissing() + { + _parser.TryParse("{{ nullValue }}", out var template, out var _); - var result = await template.RenderAsync(context); - Assert.Equal("", result); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + context.SetValue("nullValue", (object)null); - [Fact] - public async Task StrictVariables_WithBinaryExpression() - { - _parser.TryParse("{% if undefinedVar > 5 %}yes{% endif %}", out var template, out var _); + var result = await template.RenderAsync(context); + Assert.Equal("", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_NullMemberNotMissing() + { + _parser.TryParse("{{ person.Firstname }} {{ person.Lastname }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefinedVar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + options.MemberAccessStrategy.Register(); + context.SetValue("person", new Person { Firstname = null, Lastname = "Doe" }); - [Fact] - public async Task StrictVariables_MultipleRenders_ClearsTracking() - { - _parser.TryParse("{{ missing }}", out var template, out var _); + var result = await template.RenderAsync(context); + Assert.Equal(" Doe", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithBinaryExpression() + { + _parser.TryParse("{% if undefinedVar > 5 %}yes{% endif %}", out var template, out var _); - // First render should throw - await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - // Now set the variable - context.SetValue("missing", "value"); + await template.RenderAsync(context); + Assert.Contains("undefinedVar", missingVariables); + } - // Second render should succeed - var result = await template.RenderAsync(context); - Assert.Equal("value", result); - } + [Fact] + public async Task StrictVariables_MultipleRenders_ClearsTracking() + { + _parser.TryParse("{{ missing }}", out var template, out var _); - [Fact] - public async Task StrictVariables_ComplexTemplate() - { - var source = @" - {% for product in products %} - Name: {{ product.name }} - Price: {{ product.price }} - Stock: {{ product.stock }} - {% endfor %} - "; + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - _parser.TryParse(source, out var template, out var _); + // First render should track missing variable + await template.RenderAsync(context); + Assert.Contains("missing", missingVariables); - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + // Clear and set the variable + missingVariables.Clear(); + context.SetValue("missing", "value"); - var products = new[] - { - new { name = "Product 1", price = 10 }, - new { name = "Product 2", price = 20 } - }; - context.SetValue("products", products); + // 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 %} + "; - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("stock", exception.MissingVariables); - } + _parser.TryParse(source, out var template, out var _); - [Fact] - public async Task StrictVariables_WithElseIf() + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + var products = new[] { - _parser.TryParse("{% if false %}no{% elsif undefined %}maybe{% else %}yes{% endif %}", out var template, out var _); + new { name = "Product 1", price = 10 }, + new { name = "Product 2", price = 20 } + }; + context.SetValue("products", products); - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + await template.RenderAsync(context); + Assert.Contains("stock", missingVariables); + } - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("undefined", exception.MissingVariables); - } + [Fact] + public async Task StrictVariables_WithElseIf() + { + _parser.TryParse("{% if false %}no{% elsif undefined %}maybe{% else %}yes{% endif %}", out var template, out var _); - [Fact] - public async Task StrictVariables_NoOutputWhenException() - { - _parser.TryParse("Start {{ missing }} End", out var template, out var _); + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); + + await template.RenderAsync(context); + Assert.Contains("undefined", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_ProducesOutputWithMissing() + { + _parser.TryParse("Start {{ missing }} End", out var template, out var _); - // Should throw exception and produce no output - await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_WithRange() - { - _parser.TryParse("{% for i in (1..5) %}{{ i }}{% endfor %}", out var template, out var _); + // Should track missing variable and produce output + var result = await template.RenderAsync(context); + Assert.Equal("Start End", result); + Assert.Contains("missing", missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithRange() + { + _parser.TryParse("{% for i in (1..5) %}{{ i }}{% endfor %}", out var template, out var _); - // Should work fine - no missing variables - var result = await template.RenderAsync(context); - Assert.Equal("12345", result); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task StrictVariables_WithCapture() - { - _parser.TryParse("{% capture foo %}{{ bar }}{% endcapture %}{{ foo }}", out var template, out var _); + // Should work fine - no missing variables + var result = await template.RenderAsync(context); + Assert.Equal("12345", result); + Assert.Empty(missingVariables); + } - var options = new TemplateOptions { StrictVariables = true }; - var context = new TemplateContext(options); + [Fact] + public async Task StrictVariables_WithCapture() + { + _parser.TryParse("{% capture foo %}{{ bar }}{% endcapture %}{{ foo }}", out var template, out var _); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Contains("bar", exception.MissingVariables); - } + var (options, missingVariables) = CreateStrictOptions(); + var context = new TemplateContext(options); - [Fact] - public async Task UndefinedDelegate_ReceivesNotifications() - { - _parser.TryParse("{{ first }} {{ first }} {{ second }}", out var template, out var _); + 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 arguments = new List(); - var options = new TemplateOptions + var paths = new List(); + var options = new TemplateOptions + { + Undefined = path => { - Undefined = args => arguments.Add(args) - }; + paths.Add(path); + return ValueTask.FromResult(NilValue.Instance); + } + }; - var context = new TemplateContext(options); + var context = new TemplateContext(options); - var result = await template.RenderAsync(context); - Assert.True(string.IsNullOrWhiteSpace(result)); + var result = await template.RenderAsync(context); + Assert.True(string.IsNullOrWhiteSpace(result)); - Assert.Equal(3, arguments.Count); - Assert.Equal("first", arguments[0].Path); - Assert.True(arguments[0].IsFirstOccurrence); - Assert.Equal("first", arguments[1].Path); - Assert.False(arguments[1].IsFirstOccurrence); - Assert.Equal("second", arguments[2].Path); - Assert.True(arguments[2].IsFirstOccurrence); - } + Assert.Equal(3, paths.Count); + Assert.Equal("first", paths[0]); + Assert.Equal("first", paths[1]); + Assert.Equal("second", paths[2]); + } - [Fact] - public async Task StrictVariables_ThrowsAfterUndefinedDelegate() + [Fact] + public async Task UndefinedDelegate_CalledForMissingVariable() + { + _parser.TryParse("{{ missing }}", out var template, out var _); + + var paths = new List(); + var options = new TemplateOptions { - _parser.TryParse("{{ missing }}", out var template, out var _); + Undefined = path => + { + paths.Add(path); + return ValueTask.FromResult(NilValue.Instance); + } + }; + + var context = new TemplateContext(options); - var paths = new List(); - var options = new TemplateOptions + 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 = path => { - StrictVariables = true, - Undefined = args => paths.Add(args.Path) - }; + // Return a custom default value for undefined variables + return ValueTask.FromResult(new StringValue($"[{path} not found]")); + } + }; - var context = new TemplateContext(options); + var context = new TemplateContext(options); - var exception = await Assert.ThrowsAsync(() => template.RenderAsync(context).AsTask()); - Assert.Single(exception.MissingVariables); - Assert.Equal("missing", exception.MissingVariables[0]); + var result = await template.RenderAsync(context); + Assert.Equal("[missing not found] [another not found]", result); + } - Assert.Single(paths); - Assert.Equal("missing", paths[0]); - } + private (TemplateOptions, List) CreateStrictOptions() + { + var missingVariables = new List(); + + var options = new TemplateOptions + { + Undefined = path => + { + missingVariables.Add(path); + return ValueTask.FromResult(NilValue.Instance); + } + }; + + return (options, missingVariables); } } + diff --git a/Fluid/Ast/MemberExpression.cs b/Fluid/Ast/MemberExpression.cs index 10f5ae9b..44a0d1e9 100644 --- a/Fluid/Ast/MemberExpression.cs +++ b/Fluid/Ast/MemberExpression.cs @@ -32,13 +32,25 @@ public override ValueTask EvaluateAsync(TemplateContext context) var initial = _segments[0] as IdentifierSegment; + // Search the initial segment in the local scope first + + var value = context.LocalScope.GetValue(initial.Identifier); + + // If it was not successful, try again with a member of the model + var start = 1; - if (!context.LocalScope.TryGetValue(initial.Identifier, out var value)) + if (value.IsNil()) { if (context.Model == null) { - return new ValueTask(context.CreateUndefinedValue(initial.Identifier)); + // Check equality as IsNil() is also true for EmptyValue + if (context.Undefined is not null && value == NilValue.Instance) + { + return context.Undefined.Invoke(initial.Identifier); + } + + return new ValueTask(NilValue.Instance); } start = 0; diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index 575926fb..c202287f 100644 --- a/Fluid/Parser/FluidTemplate.cs +++ b/Fluid/Parser/FluidTemplate.cs @@ -34,10 +34,10 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon ExceptionHelper.ThrowArgumentNullException(nameof(context)); } - if (context.ShouldTrackUndefined) - { - context.ClearMissingVariables(); - } + // if (context.ShouldTrackUndefined) + // { + // context.ClearMissingVariables(); + // } var count = Statements.Count; for (var i = 0; i < count; i++) @@ -55,11 +55,6 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon } } - if (context.Options.StrictVariables && context.HasMissingVariables) - { - throw new StrictVariableException(context.GetMissingVariables()); - } - return new ValueTask(); } @@ -76,11 +71,6 @@ private static async ValueTask Awaited( { await statements[i].WriteToAsync(writer, encoder, context); } - - if (context.Options.StrictVariables && context.HasMissingVariables) - { - throw new StrictVariableException(context.GetMissingVariables()); - } } } } diff --git a/Fluid/Scope.cs b/Fluid/Scope.cs index e6cc3a77..bf60825d 100644 --- a/Fluid/Scope.cs +++ b/Fluid/Scope.cs @@ -48,23 +48,6 @@ public Scope(Scope parent, bool forLoopScope, StringComparer stringComparer = nu /// The name of the value to return. [MethodImpl(MethodImplOptions.AggressiveInlining)] public FluidValue GetValue(string name) - { - if (TryGetValue(name, out var value)) - { - return value; - } - - return NilValue.Instance; - } - - /// - /// Attempts to retrieve the value with the specified name in the chain of scopes. - /// - /// The name of the value to return. - /// When this method returns, contains the value associated with the specified name, if found; otherwise . - /// true if the value was found; otherwise, false. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGetValue(string name, out FluidValue value) { if (name == null) { @@ -73,17 +56,12 @@ public bool TryGetValue(string name, out FluidValue value) if (_properties != null && _properties.TryGetValue(name, out var result)) { - value = result; - return true; - } - - if (Parent != null) - { - return Parent.TryGetValue(name, out value); + return result; } - value = NilValue.Instance; - return false; + return Parent != null + ? Parent.GetValue(name) + : NilValue.Instance; } /// diff --git a/Fluid/StrictVariableException.cs b/Fluid/StrictVariableException.cs deleted file mode 100644 index ff2477ce..00000000 --- a/Fluid/StrictVariableException.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Fluid -{ - /// - /// Exception thrown when StrictVariables is enabled and undefined template variables are accessed. - /// - public class StrictVariableException : InvalidOperationException - { - /// - /// Initializes a new instance of the class. - /// - /// The list of missing variable paths. - public StrictVariableException(IReadOnlyList missingVariables) - : base() - { - MissingVariables = missingVariables ?? throw new ArgumentNullException(nameof(missingVariables)); - } - - /// - /// Initializes a new instance of the class. - /// - /// The list of missing variable paths. - /// The exception that is the cause of the current exception. - public StrictVariableException(IReadOnlyList missingVariables, Exception innerException) - : base(null, innerException) - { - MissingVariables = missingVariables ?? throw new ArgumentNullException(nameof(missingVariables)); - } - - /// - /// Gets the collection of missing variable paths. - /// - public IReadOnlyList MissingVariables { get; } - - /// - /// Gets a message that describes the current exception. - /// - public override string Message => - $"The following variables were not found: {string.Join(", ", MissingVariables)}"; - } -} diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index 208e25c1..c3868ebe 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -56,6 +56,7 @@ public TemplateContext(TemplateOptions options, StringComparer modelNamesCompare TimeZone = options.TimeZone; Captured = options.Captured; Assigned = options.Assigned; + Undefined = options.Undefined; Now = options.Now; MaxSteps = options.MaxSteps; ModelNamesComparer = modelNamesComparer; @@ -138,7 +139,6 @@ public void IncrementSteps() internal Scope RootScope { get; set; } private Dictionary _ambientValues; - private HashSet _missingVariables; /// /// Used to define custom object on this instance to be used in filters and statements @@ -146,47 +146,6 @@ public void IncrementSteps() /// public Dictionary AmbientValues => _ambientValues ??= new Dictionary(); - internal bool ShouldTrackUndefined => Options.StrictVariables || Options.Undefined != null; - - internal FluidValue CreateUndefinedValue(string path) - { - if (!ShouldTrackUndefined) - { - return NilValue.Instance; - } - - return new UndefinedValue(path, this); - } - - internal void NotifyUndefinedUsage(string path) - { - if (!ShouldTrackUndefined) - { - return; - } - - _missingVariables ??= new HashSet(StringComparer.Ordinal); - var isFirst = _missingVariables.Add(path); - - var handler = Options.Undefined; - if (handler != null) - { - handler(new UndefinedVariableEventArgs(this, path, isFirst)); - } - } - - internal IReadOnlyList GetMissingVariables() - { - return _missingVariables?.ToList() ?? new List(); - } - - internal bool HasMissingVariables => _missingVariables != null && _missingVariables.Count > 0; - - internal void ClearMissingVariables() - { - _missingVariables?.Clear(); - } - /// /// 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. @@ -208,6 +167,11 @@ internal void ClearMissingVariables() /// 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. @@ -268,12 +232,7 @@ public void ReleaseScope() /// The name of the value. public FluidValue GetValue(string name) { - if (LocalScope.TryGetValue(name, out var value)) - { - return value; - } - - return CreateUndefinedValue(name); + return LocalScope.GetValue(name); } /// @@ -315,7 +274,7 @@ public static TemplateContext SetValue(this TemplateContext context, string name { if (value == null) { - return context.SetValue(name, NilValue.Instance); + return context.SetValue(name, EmptyValue.Instance); } return context.SetValue(name, FluidValue.Create(value, context.Options)); diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index d92703f2..e50f4287 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -20,11 +20,9 @@ public class TemplateOptions /// The value which should be captured. public delegate ValueTask CapturedDelegate(string identifier, FluidValue value, TemplateContext context); - /// - /// Represents the method that will handle undefined variable notifications. - /// - /// The event arguments containing context information. - public delegate void UndefinedVariableDelegate(UndefinedVariableEventArgs args); + /// 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(); @@ -109,7 +107,7 @@ public class TemplateOptions /// /// Gets or sets the delegate to execute when an undefined value is encountered during rendering. /// - public UndefinedVariableDelegate Undefined { get; set; } + public UndefinedDelegate Undefined { get; set; } /// /// Gets or sets the instance used by the json filter. @@ -131,7 +129,7 @@ public class TemplateOptions /// When true, accessing undefined variables will collect all missing variables and throw a StrictVariableException. /// Default is false. /// - public bool StrictVariables { get; set; } = false; + // public bool StrictVariables { get; set; } = false; public TemplateOptions() { diff --git a/Fluid/UndefinedVariableEventArgs.cs b/Fluid/UndefinedVariableEventArgs.cs deleted file mode 100644 index 6d8a01cb..00000000 --- a/Fluid/UndefinedVariableEventArgs.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Fluid -{ - /// - /// Provides data for undefined variable notifications raised by . - /// - public sealed class UndefinedVariableEventArgs - { - internal UndefinedVariableEventArgs(TemplateContext context, string path, bool isFirstOccurrence) - { - Context = context ?? throw new ArgumentNullException(nameof(context)); - Path = path ?? throw new ArgumentNullException(nameof(path)); - IsFirstOccurrence = isFirstOccurrence; - } - - /// - /// Gets the in which the undefined variable was encountered. - /// - public TemplateContext Context { get; } - - /// - /// Gets the path that was evaluated and resolved to an undefined value. - /// - public string Path { get; } - - /// - /// Gets a value indicating whether this is the first time this undefined path was observed during the render. - /// - public bool IsFirstOccurrence { get; } - } -} diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index 0d865dff..6c0f9162 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -28,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); @@ -85,7 +82,12 @@ public override ValueTask GetValueAsync(string name, TemplateContext return Create(accessor.Get(Value, name, context), context.Options); } - return new ValueTask(context.CreateUndefinedValue(name)); + if (context.Undefined is not null) + { + return context.Undefined.Invoke(name); + } + + return new ValueTask(NilValue.Instance); static async ValueTask Awaited( @@ -102,19 +104,16 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon { var members = name.Split(MemberSeparators); var target = Value; - var builder = new StringBuilder(); - var first = true; + List segments = null; foreach (var prop in members) { - if (!first) + if (context.Undefined is not null) { - builder.Append('.'); + segments ??= []; + segments.Add(prop); } - builder.Append(prop); - first = false; - if (target == null) { return NilValue.Instance; @@ -124,7 +123,11 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon if (accessor == null) { - return context.CreateUndefinedValue(builder.ToString()); + if (context.Undefined is not null) + { + return await context.Undefined.Invoke(string.Join(".", segments)); + } + return NilValue.Instance; } if (accessor is IAsyncMemberAccessor asyncAccessor) diff --git a/Fluid/Values/UndefinedValue.cs b/Fluid/Values/UndefinedValue.cs deleted file mode 100644 index ab95f741..00000000 --- a/Fluid/Values/UndefinedValue.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Globalization; -using System.Text.Encodings.Web; - -namespace Fluid.Values -{ - internal sealed class UndefinedValue : FluidValue - { - private readonly TemplateContext _context; - private readonly string _path; - private bool _notified; - - public UndefinedValue(string path, TemplateContext context) - { - _path = path ?? throw new ArgumentNullException(nameof(path)); - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public string Path => _path; - - private void NotifyUsage() - { - if (_notified) - { - return; - } - - _notified = true; - _context.NotifyUndefinedUsage(_path); - } - - internal UndefinedValue AppendProperty(string property) - { - if (string.IsNullOrEmpty(property)) - { - return this; - } - - return new UndefinedValue($"{_path}.{property}", _context); - } - - internal UndefinedValue AppendIndexer(string index) - { - if (string.IsNullOrEmpty(index)) - { - return this; - } - - return new UndefinedValue($"{_path}[{index}]", _context); - } - - public override bool Equals(FluidValue other) - { - NotifyUsage(); - return other == NilValue.Instance || other.IsNil(); - } - - public override bool ToBooleanValue() - { - NotifyUsage(); - return false; - } - - public override decimal ToNumberValue() - { - NotifyUsage(); - return 0; - } - - public override string ToStringValue() - { - NotifyUsage(); - return string.Empty; - } - - public override object ToObjectValue() - { - NotifyUsage(); - return null; - } - - public override ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) - { - AssertWriteToParameters(writer, encoder, cultureInfo); - NotifyUsage(); - return default; - } - - [Obsolete("WriteTo is obsolete, prefer the WriteToAsync method.")] - public override void WriteTo(TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) - { - AssertWriteToParameters(writer, encoder, cultureInfo); - NotifyUsage(); - } - - public override ValueTask GetValueAsync(string name, TemplateContext context) - { - return new ValueTask(AppendProperty(name)); - } - - public override ValueTask GetIndexAsync(FluidValue index, TemplateContext context) - { - var value = index.ToStringValue(); - return new ValueTask(AppendIndexer(value)); - } - - public override ValueTask InvokeAsync(FunctionArguments arguments, TemplateContext context) - { - NotifyUsage(); - return new ValueTask(NilValue.Instance); - } - - public override FluidValues Type => FluidValues.Nil; - - public override bool IsNil() - { - NotifyUsage(); - return true; - } - - public override IEnumerable Enumerate(TemplateContext context) - { - NotifyUsage(); - return Array.Empty(); - } - } -} From dbc75987ed71336b2044ee37b3a051640a1bc896 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Sat, 1 Nov 2025 15:00:42 -0700 Subject: [PATCH 4/9] Update documentation --- Fluid/Parser/FluidTemplate.cs | 5 --- Fluid/TemplateOptions.cs | 7 ---- README.md | 68 +++++++++++++++++++++-------------- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index c202287f..5859bcc7 100644 --- a/Fluid/Parser/FluidTemplate.cs +++ b/Fluid/Parser/FluidTemplate.cs @@ -34,11 +34,6 @@ public ValueTask RenderAsync(TextWriter writer, TextEncoder encoder, TemplateCon ExceptionHelper.ThrowArgumentNullException(nameof(context)); } - // if (context.ShouldTrackUndefined) - // { - // context.ClearMissingVariables(); - // } - var count = Statements.Count; for (var i = 0; i < count; i++) { diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index e50f4287..64e396e8 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -124,13 +124,6 @@ public class TemplateOptions /// public bool Greedy { get; set; } = true; - /// - /// Gets or sets whether to throw an exception when a template variable is not found. - /// When true, accessing undefined variables will collect all missing variables and throw a StrictVariableException. - /// Default is false. - /// - // public bool StrictVariables { get; set; } = false; - public TemplateOptions() { Filters.WithArrayFilters() diff --git a/README.md b/README.md index 8d28ee42..e98de0ea 100644 --- a/README.md +++ b/README.md @@ -241,60 +241,74 @@ It can also be used to replace custom member access by customizing `GetValueAsyn
-## Handling undefined variables +## Handling undefined values -Fluid evaluates members lazily, so undefined identifiers can be detected precisely when they are consumed. You can opt into strict validation or simply observe the accesses. +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. -### Strict enforcement +### Tracking undefined values -Enable `TemplateOptions.StrictVariables` to aggregate every missing variable that is evaluated while rendering. Fluid throws a `StrictVariableException` after the template finishes, and the exception exposes the full list via `MissingVariables`. +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 = path => + { + missingVariables.Add(path); + 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 { - StrictVariables = true + Undefined = path => + { + // Return a custom default value for undefined variables + return ValueTask.FromResult(new StringValue($"[{path} not found]")); + } }; var template = FluidTemplate.Parse("Hello {{ user.name }} in {{ city }}!"); var context = new TemplateContext(options); -try -{ - await template.RenderAsync(context); -} -catch (StrictVariableException ex) -{ - // ex.MissingVariables contains ["user.name", "city"] -} +var result = await template.RenderAsync(context); +// Outputs: "Hello [user.name not found] in [city not found]!" ``` -### Observing undefined accesses - -To log, collect metrics, or apply custom policies, assign `TemplateOptions.Undefined`. The delegate receives an `UndefinedVariableEventArgs` instance each time a path resolves to an undefined value, including: +### Logging undefined accesses -- `Path`: the path that was evaluated (nested identifiers are dot-separated). -- `Context`: the active `TemplateContext`. -- `IsFirstOccurrence`: `true` only the first time that path is seen during the render. +You can use the `Undefined` delegate to log missing values for debugging or monitoring: ```csharp var options = new TemplateOptions { - Undefined = args => + Undefined = path => { - if (args.IsFirstOccurrence) - { - Console.WriteLine($"Missing variable: {args.Path}"); - } + Console.WriteLine($"Missing variable: {path}"); + return ValueTask.FromResult(NilValue.Instance); } }; -var template = FluidTemplate.Parse("{{ first }} {{ first }} {{ second }}"); +var template = FluidTemplate.Parse("{{ first }} {{ second }}"); var context = new TemplateContext(options); await template.RenderAsync(context); +// Logs: "Missing variable: first" +// Logs: "Missing variable: second" ``` -The callback runs regardless of the strict-variable setting. When strict mode is enabled and a handler is provided, the notifications are raised before `StrictVariableException` is thrown, allowing you to fail fast or defer the decision. -
### Inheritance From 961ae4b466cc5863187fad409f385fa0f3fdf385 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Sat, 1 Nov 2025 15:02:30 -0700 Subject: [PATCH 5/9] Update variable name --- Fluid.Tests/StrictVariableTests.cs | 16 ++++++++-------- README.md | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs index 0760d102..3be1258c 100644 --- a/Fluid.Tests/StrictVariableTests.cs +++ b/Fluid.Tests/StrictVariableTests.cs @@ -415,9 +415,9 @@ public async Task UndefinedDelegate_ReceivesNotifications() var paths = new List(); var options = new TemplateOptions { - Undefined = path => + Undefined = name => { - paths.Add(path); + paths.Add(name); return ValueTask.FromResult(NilValue.Instance); } }; @@ -441,9 +441,9 @@ public async Task UndefinedDelegate_CalledForMissingVariable() var paths = new List(); var options = new TemplateOptions { - Undefined = path => + Undefined = name => { - paths.Add(path); + paths.Add(name); return ValueTask.FromResult(NilValue.Instance); } }; @@ -463,10 +463,10 @@ public async Task UndefinedDelegate_CanReturnCustomValue() var options = new TemplateOptions { - Undefined = path => + Undefined = name => { // Return a custom default value for undefined variables - return ValueTask.FromResult(new StringValue($"[{path} not found]")); + return ValueTask.FromResult(new StringValue($"[{name} not found]")); } }; @@ -482,9 +482,9 @@ public async Task UndefinedDelegate_CanReturnCustomValue() var options = new TemplateOptions { - Undefined = path => + Undefined = name => { - missingVariables.Add(path); + missingVariables.Add(name); return ValueTask.FromResult(NilValue.Instance); } }; diff --git a/README.md b/README.md index e98de0ea..8f4e1e80 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,9 @@ To track missing values during template rendering, assign a delegate to `Templat var missingVariables = new List(); var context = new TemplateContext(); -context.Undefined = path => +context.Undefined = name => { - missingVariables.Add(path); + missingVariables.Add(name); return ValueTask.FromResult(NilValue.Instance); } }; @@ -274,10 +274,10 @@ The `Undefined` delegate can return a custom `FluidValue` to provide fallback va ```csharp var options = new TemplateOptions { - Undefined = path => + Undefined = name => { // Return a custom default value for undefined variables - return ValueTask.FromResult(new StringValue($"[{path} not found]")); + return ValueTask.FromResult(new StringValue($"[{name} not found]")); } }; From f6ca444efc043819dbf67b55b5fe1160916f2a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Sat, 1 Nov 2025 15:16:00 -0700 Subject: [PATCH 6/9] Update Fluid/Values/ObjectValueBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Fluid/Values/ObjectValueBase.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index 6c0f9162..9f7639aa 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -104,13 +104,12 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon { var members = name.Split(MemberSeparators); var target = Value; - List segments = null; + List segments = context.Undefined is not null ? [] : null; foreach (var prop in members) { if (context.Undefined is not null) { - segments ??= []; segments.Add(prop); } From abc0bf5f56f8be2ac079442a4358658749bc99f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Sat, 1 Nov 2025 15:18:09 -0700 Subject: [PATCH 7/9] Update Fluid.Tests/StrictVariableTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Fluid.Tests/StrictVariableTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Fluid.Tests/StrictVariableTests.cs b/Fluid.Tests/StrictVariableTests.cs index 3be1258c..ddf39cf5 100644 --- a/Fluid.Tests/StrictVariableTests.cs +++ b/Fluid.Tests/StrictVariableTests.cs @@ -9,9 +9,9 @@ namespace Fluid.Tests; public class StrictVariableTests { #if COMPILED - private static FluidParser _parser = new FluidParser().Compile(); + private static readonly FluidParser _parser = new FluidParser().Compile(); #else - private static FluidParser _parser = new FluidParser(); + private static readonly FluidParser _parser = new FluidParser(); #endif [Fact] From a6bf0e2627d16df1eb8817bda9dae455ac3ef270 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Sat, 1 Nov 2025 16:32:04 -0700 Subject: [PATCH 8/9] Re-introduce UndefinedValue --- Fluid.Tests/FromStatementTests.cs | 2 +- Fluid.Tests/MiscFiltersTests.cs | 4 +- Fluid.Tests/TemplateContextTests.cs | 79 +++++++++++++++++++++++++++++ Fluid/Ast/MemberExpression.cs | 6 +-- Fluid/Filters/ColorFilters.cs | 44 ++++++++-------- Fluid/Scope.cs | 2 +- Fluid/TemplateContext.cs | 2 +- Fluid/Values/EmptyValue.cs | 3 +- Fluid/Values/FluidValues.cs | 18 +++---- Fluid/Values/NilValue.cs | 21 +++++--- Fluid/Values/ObjectValueBase.cs | 2 +- Fluid/Values/UndefinedValue.cs | 11 ++++ 12 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 Fluid/Values/UndefinedValue.cs 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 9077821a..94b663bf 100644 --- a/Fluid.Tests/MiscFiltersTests.cs +++ b/Fluid.Tests/MiscFiltersTests.cs @@ -923,7 +923,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(); @@ -945,7 +945,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/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 44a0d1e9..33ae5975 100644 --- a/Fluid/Ast/MemberExpression.cs +++ b/Fluid/Ast/MemberExpression.cs @@ -44,13 +44,13 @@ public override ValueTask EvaluateAsync(TemplateContext context) { if (context.Model == null) { - // Check equality as IsNil() is also true for EmptyValue - if (context.Undefined is not null && value == NilValue.Instance) + // 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 new ValueTask(NilValue.Instance); + return new ValueTask(value); } start = 0; 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/Scope.cs b/Fluid/Scope.cs index bf60825d..9fe768dc 100644 --- a/Fluid/Scope.cs +++ b/Fluid/Scope.cs @@ -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 c3868ebe..6d0386e5 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -274,7 +274,7 @@ public static TemplateContext SetValue(this TemplateContext context, string name { if (value == null) { - return context.SetValue(name, EmptyValue.Instance); + return context.SetValue(name, NilValue.Instance); } return context.SetValue(name, FluidValue.Create(value, context.Options)); 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 9f7639aa..fc3ff918 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -126,7 +126,7 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon { return await context.Undefined.Invoke(string.Join(".", segments)); } - return NilValue.Instance; + return UndefinedValue.Instance; } if (accessor is IAsyncMemberAccessor asyncAccessor) 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() + { + } + } +} From 878e6311d3ba2d7dd647ccecd7004361d435e5d0 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 4 Nov 2025 07:38:34 -0800 Subject: [PATCH 9/9] Fix build --- Fluid.Tests/MiscFiltersTests.cs | 2 +- Fluid/TemplateContext.cs | 1 + Fluid/TemplateOptions.cs | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Fluid.Tests/MiscFiltersTests.cs b/Fluid.Tests/MiscFiltersTests.cs index 81c5015d..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] diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index 761adf5c..6c95215d 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; +using Fluid.Values; namespace Fluid { diff --git a/Fluid/TemplateOptions.cs b/Fluid/TemplateOptions.cs index db77c8d1..8c6dcc1c 100644 --- a/Fluid/TemplateOptions.cs +++ b/Fluid/TemplateOptions.cs @@ -3,8 +3,6 @@ using Fluid.Filters; using Fluid.Values; using Microsoft.Extensions.FileProviders; -using System.Globalization; -using System.Text.Encodings.Web; using System.Text.Json; namespace Fluid