From ab672eec6d4a18ca33d553174aa50142dcf11651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:21:08 -0700 Subject: [PATCH 1/5] Optimize member expression access Canonicalize identifiers per template and let built-in member accessors return FluidValue directly on the rendering hot path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Fluid.Tests/MemberAccessStrategyTests.cs | 28 +++++++ Fluid.Tests/ParserTests.cs | 21 ++++++ Fluid/Accessors/AccessorValueConverter.cs | 6 ++ Fluid/Accessors/FieldInfoAccessor.cs | 5 +- Fluid/Accessors/IFluidValueAccessor.cs | 8 ++ Fluid/Accessors/Invoker.cs | 8 ++ Fluid/Accessors/PropertyInfoAccessor.cs | 5 +- .../Accessors/ReflectionFieldInfoAccessor.cs | 12 ++- .../ReflectionPropertyInfoAccessor.cs | 12 ++- Fluid/FluidParser.cs | 10 ++- Fluid/Parser/FluidParseContext.cs | 75 +++++++++++++++++++ Fluid/Values/ObjectValueBase.cs | 6 ++ 12 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 Fluid/Accessors/IFluidValueAccessor.cs diff --git a/Fluid.Tests/MemberAccessStrategyTests.cs b/Fluid.Tests/MemberAccessStrategyTests.cs index ef1471c4..f765fea3 100644 --- a/Fluid.Tests/MemberAccessStrategyTests.cs +++ b/Fluid.Tests/MemberAccessStrategyTests.cs @@ -66,6 +66,34 @@ public void WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() Assert.Null(accessor.Get(new Class1(), nameof(Class1.Property1), new TemplateContext())); } + [Fact] + public void RegistrationsShouldInvalidateCachedRenderedAccessors() + { + var options = new TemplateOptions + { + ModelNamesComparer = StringComparer.Ordinal + }; + var model = new Class1 { Property1 = "reflected" }; + var context = new TemplateContext(options).SetValue("item", model); + var template = _parser.Parse("{{ item.Property1 }}"); + + Assert.Equal("reflected", template.Render(context)); + + options.MemberAccessStrategy.Register( + typeof(Class1), + "*", + new FixedMemberAccessor("wildcard")); + + Assert.Equal("wildcard", template.Render(context)); + + options.MemberAccessStrategy.Register( + typeof(Class1), + nameof(Class1.Property1), + new FixedMemberAccessor("exact")); + + Assert.Equal("exact", template.Render(context)); + } + [Fact] public void BaseRegistrationChangesShouldInvalidateDerivedAccessors() { diff --git a/Fluid.Tests/ParserTests.cs b/Fluid.Tests/ParserTests.cs index 86680a16..ebbbd4b0 100644 --- a/Fluid.Tests/ParserTests.cs +++ b/Fluid.Tests/ParserTests.cs @@ -102,6 +102,27 @@ public void ShouldParseForTag() Assert.IsType(statements.ElementAt(0)); } + [Fact] + public void ShouldCanonicalizeIdentifiersWithinTemplate() + { + var statements = Parse( + "{% assign product = nil %}" + + "{% for product in products %}{{ product.name }}{{ product.price }}{% endfor %}"); + + var assign = Assert.IsType(statements[0]); + var forStatement = Assert.IsType(statements[1]); + var source = Assert.IsType(forStatement.Source); + var nameOutput = Assert.IsType(forStatement.Statements[0]); + var name = Assert.IsType(nameOutput.Expression); + var priceOutput = Assert.IsType(forStatement.Statements[1]); + var price = Assert.IsType(priceOutput.Expression); + + Assert.Same(assign.Identifier, forStatement.Identifier); + Assert.Same(forStatement.Identifier, ((IdentifierSegment)name.Segments[0]).Identifier); + Assert.Same(forStatement.Identifier, ((IdentifierSegment)price.Segments[0]).Identifier); + Assert.Equal("products", ((IdentifierSegment)source.Segments[0]).Identifier); + } + [Fact] public void ShouldParseForElseTag() { diff --git a/Fluid/Accessors/AccessorValueConverter.cs b/Fluid/Accessors/AccessorValueConverter.cs index dac87b6c..76a09c94 100644 --- a/Fluid/Accessors/AccessorValueConverter.cs +++ b/Fluid/Accessors/AccessorValueConverter.cs @@ -30,4 +30,10 @@ public static object Convert(object value, TypeCode typeCode, bool isEnum) _ => value, }; } + + public static FluidValue ConvertToFluidValue(object value, TypeCode typeCode, bool isEnum, TemplateOptions options) + { + var converted = Convert(value, typeCode, isEnum); + return converted as FluidValue ?? FluidValue.Create(converted, options); + } } diff --git a/Fluid/Accessors/FieldInfoAccessor.cs b/Fluid/Accessors/FieldInfoAccessor.cs index 96ee8377..fc44f3d3 100644 --- a/Fluid/Accessors/FieldInfoAccessor.cs +++ b/Fluid/Accessors/FieldInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors { - public sealed class FieldInfoAccessor : IMemberAccessor + public sealed class FieldInfoAccessor : IMemberAccessor, IFluidValueAccessor { private readonly Invoker _invoker; @@ -53,6 +53,9 @@ public object Get(object obj, string name, TemplateContext ctx) return _invoker?.Invoke(obj, ctx.Options); } + FluidValue IFluidValueAccessor.GetFluidValue(object obj, string name, TemplateContext context) + => _invoker?.InvokeFluidValue(obj, context.Options) ?? NilValue.Instance; + private static Delegate GetGetter(FieldInfo field) { var parameterTypes = new[] { typeof(object), field.DeclaringType }; diff --git a/Fluid/Accessors/IFluidValueAccessor.cs b/Fluid/Accessors/IFluidValueAccessor.cs new file mode 100644 index 00000000..b73eb314 --- /dev/null +++ b/Fluid/Accessors/IFluidValueAccessor.cs @@ -0,0 +1,8 @@ +using Fluid.Values; + +namespace Fluid.Accessors; + +internal interface IFluidValueAccessor +{ + FluidValue GetFluidValue(object obj, string name, TemplateContext context); +} diff --git a/Fluid/Accessors/Invoker.cs b/Fluid/Accessors/Invoker.cs index 31fd6750..b5a20e91 100644 --- a/Fluid/Accessors/Invoker.cs +++ b/Fluid/Accessors/Invoker.cs @@ -5,6 +5,8 @@ namespace Fluid.Accessors; internal abstract class Invoker { public abstract object Invoke(object target, TemplateOptions options); + + public abstract FluidValue InvokeFluidValue(object target, TemplateOptions options); } internal sealed class Invoker : Invoker @@ -23,4 +25,10 @@ public override object Invoke(object target, TemplateOptions options) var result = _d((T)target); return _converter != null ? _converter(result, options) : result; } + + public override FluidValue InvokeFluidValue(object target, TemplateOptions options) + { + var result = _d((T)target); + return _converter != null ? _converter(result, options) : FluidValue.Create(result, options); + } } diff --git a/Fluid/Accessors/PropertyInfoAccessor.cs b/Fluid/Accessors/PropertyInfoAccessor.cs index 8d34a122..888d979b 100644 --- a/Fluid/Accessors/PropertyInfoAccessor.cs +++ b/Fluid/Accessors/PropertyInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors; -public sealed class PropertyInfoAccessor : IMemberAccessor +public sealed class PropertyInfoAccessor : IMemberAccessor, IFluidValueAccessor { private readonly Invoker _invoker; @@ -67,6 +67,9 @@ public PropertyInfoAccessor(PropertyInfo propertyInfo) public object Get(object obj, string name, TemplateContext ctx) => _invoker.Invoke(obj, ctx.Options); + FluidValue IFluidValueAccessor.GetFluidValue(object obj, string name, TemplateContext context) + => _invoker.InvokeFluidValue(obj, context.Options); + private static Delegate GetGetter(Type declaringType, string fieldName) { string[] names = [fieldName.ToLowerInvariant(), $"<{fieldName}>k__BackingField", $"_{fieldName.ToLowerInvariant()}"]; diff --git a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs index 82c55d3c..8feccbdf 100644 --- a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs @@ -1,8 +1,9 @@ +using Fluid.Values; using System.Reflection; namespace Fluid.Accessors; -internal sealed class ReflectionFieldInfoAccessor : IMemberAccessor +internal sealed class ReflectionFieldInfoAccessor : IMemberAccessor, IFluidValueAccessor { private readonly FieldInfo _fieldInfo; private readonly TypeCode _typeCode; @@ -19,4 +20,13 @@ public object Get(object obj, string name, TemplateContext ctx) { return AccessorValueConverter.Convert(_fieldInfo.GetValue(obj), _typeCode, _isEnum); } + + public FluidValue GetFluidValue(object obj, string name, TemplateContext context) + { + return AccessorValueConverter.ConvertToFluidValue( + _fieldInfo.GetValue(obj), + _typeCode, + _isEnum, + context.Options); + } } diff --git a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs index 81357257..c8cfca93 100644 --- a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs @@ -1,8 +1,9 @@ +using Fluid.Values; using System.Reflection; namespace Fluid.Accessors; -internal sealed class ReflectionPropertyInfoAccessor : IMemberAccessor +internal sealed class ReflectionPropertyInfoAccessor : IMemberAccessor, IFluidValueAccessor { private readonly PropertyInfo _propertyInfo; private readonly TypeCode _typeCode; @@ -19,4 +20,13 @@ public object Get(object obj, string name, TemplateContext ctx) { return AccessorValueConverter.Convert(_propertyInfo.GetValue(obj), _typeCode, _isEnum); } + + public FluidValue GetFluidValue(object obj, string name, TemplateContext context) + { + return AccessorValueConverter.ConvertToFluidValue( + _propertyInfo.GetValue(obj), + _typeCode, + _isEnum, + context.Options); + } } diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index ccc86fd2..43bda550 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -95,8 +95,14 @@ public FluidParser(FluidParserOptions parserOptions) TagEnd = NoInlineTagEnd; } - Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestionMark)).Then(x => x.ToString()); - VariableSignature = SkipWhiteSpace(new VariableSignatureParser()).Then(x => x.ToString()); + Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestionMark)) + .Then((context, x) => context is FluidParseContext fluidContext + ? fluidContext.CanonicalizeIdentifier(x.ToString()) + : x.ToString()); + VariableSignature = SkipWhiteSpace(new VariableSignatureParser()) + .Then((context, x) => context is FluidParseContext fluidContext + ? fluidContext.CanonicalizeIdentifier(x.ToString()) + : x.ToString()); String.Name = "String"; diff --git a/Fluid/Parser/FluidParseContext.cs b/Fluid/Parser/FluidParseContext.cs index 58f53de7..d2092509 100644 --- a/Fluid/Parser/FluidParseContext.cs +++ b/Fluid/Parser/FluidParseContext.cs @@ -6,10 +6,85 @@ namespace Fluid.Parser { public class FluidParseContext : ParseContext { + private const int InlineIdentifierCapacity = 8; + + private string _identifier0, _identifier1, _identifier2, _identifier3; + private string _identifier4, _identifier5, _identifier6, _identifier7; + private int _identifierCount; + private Dictionary _identifiers; + public FluidParseContext(string text) : base(new Scanner(text)) { } + // Keep identifier references local to this parse so repeated scope names can use reference equality. + internal string CanonicalizeIdentifier(string identifier) + { + if (_identifiers is not null) + { + if (_identifiers.TryGetValue(identifier, out var canonical)) + { + return canonical; + } + + _identifiers.Add(identifier, identifier); + return identifier; + } + + for (var i = 0; i < _identifierCount; i++) + { + var canonical = GetIdentifier(i); + + if (string.Equals(identifier, canonical, StringComparison.Ordinal)) + { + return canonical; + } + } + + if (_identifierCount < InlineIdentifierCapacity) + { + SetIdentifier(_identifierCount++, identifier); + return identifier; + } + + _identifiers = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < InlineIdentifierCapacity; i++) + { + var canonical = GetIdentifier(i); + _identifiers.Add(canonical, canonical); + } + + _identifiers.Add(identifier, identifier); + return identifier; + } + + private string GetIdentifier(int index) => index switch + { + 0 => _identifier0, + 1 => _identifier1, + 2 => _identifier2, + 3 => _identifier3, + 4 => _identifier4, + 5 => _identifier5, + 6 => _identifier6, + _ => _identifier7, + }; + + private void SetIdentifier(int index, string identifier) + { + switch (index) + { + case 0: _identifier0 = identifier; break; + case 1: _identifier1 = identifier; break; + case 2: _identifier2 = identifier; break; + case 3: _identifier3 = identifier; break; + case 4: _identifier4 = identifier; break; + case 5: _identifier5 = identifier; break; + case 6: _identifier6 = identifier; break; + default: _identifier7 = identifier; break; + } + } + public string PreviousRenderTag { get; set; } public TextSpanStatement PreviousTextSpanStatement { get; set; } public bool StripNextTextSpanStatement { get; set; } diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index d13a547e..fe3b2527 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using System.Text.Encodings.Web; +using Fluid.Accessors; using Fluid.Utils; namespace Fluid.Values @@ -174,6 +175,11 @@ private ValueTask GetValueAsync(string name, TemplateContext context return Awaited(asyncAccessor, Value, name, context); } + if (accessor is IFluidValueAccessor fluidValueAccessor) + { + return fluidValueAccessor.GetFluidValue(Value, name, context); + } + return Create(accessor.Get(Value, name, context), context.Options); } From 43a5a1b2b0c2d3c642a8812cf9a9cced2aaa2f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:09:52 -0700 Subject: [PATCH 2/5] Unify member accessor contract Replace the split synchronous, asynchronous, and FluidValue accessor interfaces with a single MemberAccessor abstraction that returns ValueTask. Update runtime, reflection, generated, and registered accessor paths to use the engine-native value contract directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a5b2c42-bcdc-4c1f-b0c4-26e49441e90b --- AGENTS.md | 2 +- .../MemberAccessorGenerator.cs | 96 ++----------------- Fluid.Tests/MemberAccessStrategyTests.cs | 15 +-- Fluid.Tests/MemberAccessorGeneratorTests.cs | 9 +- Fluid.Tests/RenderCacheInvalidationTests.cs | 2 +- Fluid/Accessors/AccessorValueConverter.cs | 19 ++-- Fluid/Accessors/AsyncDelegateAccessorOfT.cs | 15 ++- Fluid/Accessors/DelegateAccessorOfT.cs | 10 +- Fluid/Accessors/FieldInfoAccessor.cs | 11 +-- Fluid/Accessors/IFluidValueAccessor.cs | 8 -- Fluid/Accessors/Invoker.cs | 18 ++-- Fluid/Accessors/MethodInfoAccessor.cs | 7 +- Fluid/Accessors/PropertyInfoAccessor.cs | 8 +- .../Accessors/ReflectionFieldInfoAccessor.cs | 13 +-- .../ReflectionPropertyInfoAccessor.cs | 13 +-- Fluid/DefaultMemberAccessStrategy.cs | 24 ++--- Fluid/IMemberAccessor.cs | 12 --- Fluid/MemberAccessStrategy.cs | 6 +- Fluid/MemberAccessStrategyExtensions.cs | 24 ++--- Fluid/MemberAccessor.cs | 41 ++++++++ Fluid/NullMemberAccessor.cs | 12 ++- Fluid/Values/ObjectValueBase.cs | 61 ++++++------ 22 files changed, 173 insertions(+), 253 deletions(-) delete mode 100644 Fluid/Accessors/IFluidValueAccessor.cs delete mode 100644 Fluid/IMemberAccessor.cs create mode 100644 Fluid/MemberAccessor.cs diff --git a/AGENTS.md b/AGENTS.md index 936452f2..dcf82636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Pipeline: **source text → Parlot grammar → `Statement` AST → async render **Options vs context** — `TemplateOptions` is shared, effectively-immutable application configuration (filters, member access strategy, culture, time zone, execution limits) and should be created once. `TemplateContext` is per-render and **not** thread-safe; it owns the scope chain, the model, and the step/recursion counters. `FluidParser` and `IFluidTemplate` instances are thread-safe and meant to be cached. -**Security / member access** — Fluid is allow-list based: a .NET member is invisible to templates until registered. `MemberAccessStrategy` + `IMemberAccessor` (`Fluid/Accessors/`) resolve members, with emit-based accessors when dynamic code is available and `Reflection*Accessor` fallbacks otherwise (NativeAOT/trimming). `Fluid.SourceGenerator/MemberAccessorGenerator.cs` generates accessors for types marked with `[FluidRegister]`; the `TemplateOptions` constructor invokes them via `RegisterGeneratedMemberAccessors()`. Changes to accessor resolution need to hold for all three paths — emit, reflection, generated. +**Security / member access** — Fluid is allow-list based: a .NET member is invisible to templates until registered. `MemberAccessStrategy` + `MemberAccessor` resolve members, with emit-based accessors when dynamic code is available and `Reflection*Accessor` fallbacks otherwise (NativeAOT/trimming). `Fluid.SourceGenerator/MemberAccessorGenerator.cs` generates accessors for types marked with `[FluidRegister]`; the `TemplateOptions` constructor invokes them via `RegisterGeneratedMemberAccessors()`. Changes to accessor resolution need to hold for all three paths — emit, reflection, generated. **Filters** — `FilterCollection` maps a name to a `FilterDelegate`. Built-ins live in `Fluid/Filters/{Array,String,Number,Misc,Color,Money}Filters.cs` and are wired up by `With*Filters()` extension methods. The `TemplateOptions` constructor calls `WithArrayFilters().WithStringFilters().WithNumberFilters().WithMiscFilters()`; `WithColorFilters()` and `WithMoneyFilters()` exist but are opt-in. A new built-in filter goes in the matching file plus its `With*Filters` method. `WithMoneyFilters()` is configured by `TemplateOptions.MoneyOptions` (`Fluid/MoneyOptions.cs`), which is mirrored on `TemplateContext` so a currency can be picked per render. diff --git a/Fluid.SourceGenerator/MemberAccessorGenerator.cs b/Fluid.SourceGenerator/MemberAccessorGenerator.cs index d7962e16..1f468508 100644 --- a/Fluid.SourceGenerator/MemberAccessorGenerator.cs +++ b/Fluid.SourceGenerator/MemberAccessorGenerator.cs @@ -480,12 +480,12 @@ private static string GetAccessibilityKeyword(Accessibility accessibility) private static void AppendAccessor(StringBuilder source, string accessorName, string typeExpression, List members) { - source.Append(" internal sealed class ").Append(accessorName).AppendLine(" : global::Fluid.IAsyncMemberAccessor"); + source.Append(" internal sealed class ").Append(accessorName).AppendLine(" : global::Fluid.MemberAccessor"); source.AppendLine(" {"); - source.AppendLine(" public object Get(object obj, string name, global::Fluid.TemplateContext ctx)"); + source.AppendLine(" public override global::System.Threading.Tasks.ValueTask GetAsync(object obj, string name, global::Fluid.TemplateContext context)"); source.AppendLine(" {"); source.Append(" var typed = (").Append(typeExpression).AppendLine(")obj;"); - source.AppendLine(" var comparer = ctx.Options.ModelNamesComparer;"); + source.AppendLine(" var comparer = context.Options.ModelNamesComparer;"); source.AppendLine(); foreach (var member in members) @@ -494,58 +494,12 @@ private static void AppendAccessor(StringBuilder source, string accessorName, st .Append(member.Name) .AppendLine("\"))"); source.AppendLine(" {"); - source.Append(" return ").Append(member.Expression).AppendLine(";"); + source.Append(" return CreateValueTask(").Append(member.Expression).AppendLine(", context);"); source.AppendLine(" }"); } source.AppendLine(); - source.AppendLine(" return null;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine(" public async global::System.Threading.Tasks.Task GetAsync(object obj, string name, global::Fluid.TemplateContext ctx)"); - source.AppendLine(" {"); - source.Append(" var typed = (").Append(typeExpression).AppendLine(")obj;"); - source.AppendLine(" var comparer = ctx.Options.ModelNamesComparer;"); - source.AppendLine(); - - foreach (var member in members) - { - source.Append(" if (comparer.Equals(name, \"") - .Append(member.Name) - .AppendLine("\"))"); - source.AppendLine(" {"); - - switch (member.AsyncKind) - { - case AsyncKind.Task: - source.Append(" var task = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await task.ConfigureAwait(false);"); - source.AppendLine(" return task.Result;"); - break; - case AsyncKind.ValueTask: - source.Append(" var valueTask = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" return await valueTask.ConfigureAwait(false);"); - break; - case AsyncKind.TaskWithoutResult: - source.Append(" var task = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await task.ConfigureAwait(false);"); - source.AppendLine(" return null;"); - break; - case AsyncKind.ValueTaskWithoutResult: - source.Append(" var valueTask = ").Append(member.Expression).AppendLine(";"); - source.AppendLine(" await valueTask.ConfigureAwait(false);"); - source.AppendLine(" return null;"); - break; - default: - source.Append(" return ").Append(member.Expression).AppendLine(";"); - break; - } - - source.AppendLine(" }"); - } - - source.AppendLine(); - source.AppendLine(" return null;"); + source.AppendLine(" return default;"); source.AppendLine(" }"); source.AppendLine(" }"); } @@ -572,7 +526,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(property.Name, expression, GetAsyncKind(property.Type))); + members.Add(new MemberAccess(property.Name, expression)); } foreach (var field in EnumerateFields(typeSymbol)) @@ -592,7 +546,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(field.Name, expression, GetAsyncKind(field.Type))); + members.Add(new MemberAccess(field.Name, expression)); } foreach (var method in EnumerateMethods(typeSymbol)) @@ -612,7 +566,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}()" : $"typed.{memberName}()"; - members.Add(new MemberAccess(method.Name, expression, GetAsyncKind(method.ReturnType))); + members.Add(new MemberAccess(method.Name, expression)); } return members; @@ -701,29 +655,6 @@ private static IEnumerable EnumerateMembers(ITypeSymbol typeSymbol) } } - private static AsyncKind GetAsyncKind(ITypeSymbol type) - { - if (type is not INamedTypeSymbol namedType) - { - return AsyncKind.None; - } - - var containingNamespace = namedType.ContainingNamespace?.ToDisplayString(); - if (!string.Equals(containingNamespace, "System.Threading.Tasks", StringComparison.Ordinal)) - { - return AsyncKind.None; - } - - return namedType.Name switch - { - "Task" when namedType.IsGenericType => AsyncKind.Task, - "Task" => AsyncKind.TaskWithoutResult, - "ValueTask" when namedType.IsGenericType => AsyncKind.ValueTask, - "ValueTask" => AsyncKind.ValueTaskWithoutResult, - _ => AsyncKind.None - }; - } - private static string CreateAccessorName(ITypeSymbol typeSymbol, HashSet usedAccessorNames) { var baseName = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); @@ -797,16 +728,7 @@ private sealed record GeneratedOptionsTypeRegistration( INamedTypeSymbol OptionsType, ImmutableArray Accessors); - private sealed record MemberAccess(string Name, string Expression, AsyncKind AsyncKind); - - private enum AsyncKind - { - None, - Task, - ValueTask, - TaskWithoutResult, - ValueTaskWithoutResult - } + private sealed record MemberAccess(string Name, string Expression); private static readonly string AttributeSource = """ // diff --git a/Fluid.Tests/MemberAccessStrategyTests.cs b/Fluid.Tests/MemberAccessStrategyTests.cs index f765fea3..4ca1ec12 100644 --- a/Fluid.Tests/MemberAccessStrategyTests.cs +++ b/Fluid.Tests/MemberAccessStrategyTests.cs @@ -50,7 +50,7 @@ public void ReflectedAccessorsShouldVaryByStringComparer() } [Fact] - public void WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() + public async Task WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() { var strategy = new DefaultMemberAccessStrategy(); var reflected = strategy.GetAccessor(typeof(Class1), nameof(Class1.Property1), StringComparer.Ordinal); @@ -63,7 +63,7 @@ public void WildcardRegistrationShouldOverridePreviouslyReflectedAccessor() var accessor = strategy.GetAccessor(typeof(Class1), nameof(Class1.Property1), StringComparer.Ordinal); Assert.NotSame(reflected, accessor); - Assert.Null(accessor.Get(new Class1(), nameof(Class1.Property1), new TemplateContext())); + Assert.Null(await accessor.GetAsync(new Class1(), nameof(Class1.Property1), new TemplateContext())); } [Fact] @@ -95,18 +95,18 @@ public void RegistrationsShouldInvalidateCachedRenderedAccessors() } [Fact] - public void BaseRegistrationChangesShouldInvalidateDerivedAccessors() + public async Task BaseRegistrationChangesShouldInvalidateDerivedAccessors() { var strategy = new DefaultMemberAccessStrategy(); strategy.Register((instance, name) => "first"); var first = strategy.GetAccessor(typeof(DerivedClass1), "custom", StringComparer.Ordinal); - Assert.Equal("first", first.Get(new DerivedClass1(), "custom", new TemplateContext())); + Assert.Equal("first", (await first.GetAsync(new DerivedClass1(), "custom", new TemplateContext())).ToObjectValue()); strategy.Register((instance, name) => "second"); var second = strategy.GetAccessor(typeof(DerivedClass1), "custom", StringComparer.Ordinal); - Assert.Equal("second", second.Get(new DerivedClass1(), "custom", new TemplateContext())); + Assert.Equal("second", (await second.GetAsync(new DerivedClass1(), "custom", new TemplateContext())).ToObjectValue()); } [Fact] @@ -432,7 +432,7 @@ void ITemplateOptionsMemberAccessorRegistrar.RegisterMemberAccessors(TemplateOpt } } - public sealed class FixedMemberAccessor : IMemberAccessor + public sealed class FixedMemberAccessor : MemberAccessor { private readonly string _value; @@ -441,6 +441,7 @@ public FixedMemberAccessor(string value) _value = value; } - public object Get(object obj, string name, TemplateContext ctx) => _value; + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => CreateValueTask(_value, context); } } diff --git a/Fluid.Tests/MemberAccessorGeneratorTests.cs b/Fluid.Tests/MemberAccessorGeneratorTests.cs index 6e3d2643..aecd285e 100644 --- a/Fluid.Tests/MemberAccessorGeneratorTests.cs +++ b/Fluid.Tests/MemberAccessorGeneratorTests.cs @@ -36,13 +36,12 @@ public static partial class FluidProfiles Assert.Contains("internal sealed class FluidRegisterAttribute", generated); Assert.Contains("public static partial void ApplyPublic(global::Fluid.TemplateOptions options)", generated); Assert.Contains("strategy.Register(typeof(global::Person), \"*\", new global::Fluid.SourceGenerated.Person_GeneratedMemberAccessor());", generated); + Assert.Contains(": global::Fluid.MemberAccessor", generated); Assert.Contains("comparer.Equals(name, \"FirstName\")", generated); Assert.Contains("comparer.Equals(name, \"Age\")", generated); - Assert.Contains("var task = typed.Loaded();", generated); - Assert.Contains("await task.ConfigureAwait(false);", generated); - Assert.Contains("var valueTask = typed.Initialized();", generated); - Assert.Contains("await valueTask.ConfigureAwait(false);", generated); - Assert.Contains("return null;", generated); + Assert.Contains("return CreateValueTask(typed.Loaded(), context);", generated); + Assert.Contains("return CreateValueTask(typed.Initialized(), context);", generated); + Assert.Contains("return default;", generated); } [Fact] diff --git a/Fluid.Tests/RenderCacheInvalidationTests.cs b/Fluid.Tests/RenderCacheInvalidationTests.cs index 69f0f24c..5a2a8ea4 100644 --- a/Fluid.Tests/RenderCacheInvalidationTests.cs +++ b/Fluid.Tests/RenderCacheInvalidationTests.cs @@ -89,7 +89,7 @@ private sealed class ToggleStrategy : DefaultMemberAccessStrategy public int GetAccessorCalls; - public override IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) + public override MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) { GetAccessorCalls++; diff --git a/Fluid/Accessors/AccessorValueConverter.cs b/Fluid/Accessors/AccessorValueConverter.cs index 76a09c94..9e4d7212 100644 --- a/Fluid/Accessors/AccessorValueConverter.cs +++ b/Fluid/Accessors/AccessorValueConverter.cs @@ -4,11 +4,16 @@ namespace Fluid.Accessors; internal static class AccessorValueConverter { - public static object Convert(object value, TypeCode typeCode, bool isEnum) + public static FluidValue Convert(object value, TypeCode typeCode, bool isEnum, TemplateOptions options) { - if (value == null || isEnum) + if (value == null) { - return value; + return null; + } + + if (isEnum) + { + return FluidValue.Create(value, options); } return typeCode switch @@ -27,13 +32,7 @@ public static object Convert(object value, TypeCode typeCode, bool isEnum) TypeCode.Decimal => NumberValue.Create((decimal)value), TypeCode.DateTime => new DateTimeValue((DateTime)value), TypeCode.String => StringValue.Create((string)value), - _ => value, + _ => FluidValue.Create(value, options), }; } - - public static FluidValue ConvertToFluidValue(object value, TypeCode typeCode, bool isEnum, TemplateOptions options) - { - var converted = Convert(value, typeCode, isEnum); - return converted as FluidValue ?? FluidValue.Create(converted, options); - } } diff --git a/Fluid/Accessors/AsyncDelegateAccessorOfT.cs b/Fluid/Accessors/AsyncDelegateAccessorOfT.cs index 28863a38..d15cd980 100644 --- a/Fluid/Accessors/AsyncDelegateAccessorOfT.cs +++ b/Fluid/Accessors/AsyncDelegateAccessorOfT.cs @@ -1,6 +1,8 @@ -namespace Fluid.Accessors +using Fluid.Values; + +namespace Fluid.Accessors { - public class AsyncDelegateAccessor : IAsyncMemberAccessor + public class AsyncDelegateAccessor : MemberAccessor { private readonly Func> _getter; @@ -9,19 +11,14 @@ public AsyncDelegateAccessor(Func> get _getter = getter; } - public object Get(object obj, string name, TemplateContext ctx) - { - throw new NotImplementedException(); - } - public Task GetAsync(T obj, string name, TemplateContext ctx) { return _getter(obj, name, ctx); } - async Task IAsyncMemberAccessor.GetAsync(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return await _getter((T)obj, name, ctx); + return CreateValueTask(_getter((T)obj, name, context), context); } } } diff --git a/Fluid/Accessors/DelegateAccessorOfT.cs b/Fluid/Accessors/DelegateAccessorOfT.cs index 0047a58e..3a096f22 100644 --- a/Fluid/Accessors/DelegateAccessorOfT.cs +++ b/Fluid/Accessors/DelegateAccessorOfT.cs @@ -1,6 +1,8 @@ -namespace Fluid.Accessors +using Fluid.Values; + +namespace Fluid.Accessors { - public class DelegateAccessor : IMemberAccessor + public class DelegateAccessor : MemberAccessor { private readonly Func _getter; @@ -9,9 +11,9 @@ public DelegateAccessor(Func getter) _getter = getter; } - object IMemberAccessor.Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return _getter((T)obj, name, ctx); + return CreateValueTask(_getter((T)obj, name, context), context); } } } diff --git a/Fluid/Accessors/FieldInfoAccessor.cs b/Fluid/Accessors/FieldInfoAccessor.cs index fc44f3d3..d2b568c7 100644 --- a/Fluid/Accessors/FieldInfoAccessor.cs +++ b/Fluid/Accessors/FieldInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors { - public sealed class FieldInfoAccessor : IMemberAccessor, IFluidValueAccessor + public sealed class FieldInfoAccessor : MemberAccessor { private readonly Invoker _invoker; @@ -48,13 +48,8 @@ public FieldInfoAccessor(FieldInfo fieldInfo) _invoker = (Invoker) Activator.CreateInstance(invokerType, [d, converter]); } - public object Get(object obj, string name, TemplateContext ctx) - { - return _invoker?.Invoke(obj, ctx.Options); - } - - FluidValue IFluidValueAccessor.GetFluidValue(object obj, string name, TemplateContext context) - => _invoker?.InvokeFluidValue(obj, context.Options) ?? NilValue.Instance; + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => new(_invoker?.Invoke(obj, context.Options)); private static Delegate GetGetter(FieldInfo field) { diff --git a/Fluid/Accessors/IFluidValueAccessor.cs b/Fluid/Accessors/IFluidValueAccessor.cs deleted file mode 100644 index b73eb314..00000000 --- a/Fluid/Accessors/IFluidValueAccessor.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Fluid.Values; - -namespace Fluid.Accessors; - -internal interface IFluidValueAccessor -{ - FluidValue GetFluidValue(object obj, string name, TemplateContext context); -} diff --git a/Fluid/Accessors/Invoker.cs b/Fluid/Accessors/Invoker.cs index b5a20e91..30db7bcd 100644 --- a/Fluid/Accessors/Invoker.cs +++ b/Fluid/Accessors/Invoker.cs @@ -4,9 +4,7 @@ namespace Fluid.Accessors; internal abstract class Invoker { - public abstract object Invoke(object target, TemplateOptions options); - - public abstract FluidValue InvokeFluidValue(object target, TemplateOptions options); + public abstract FluidValue Invoke(object target, TemplateOptions options); } internal sealed class Invoker : Invoker @@ -20,15 +18,13 @@ public Invoker(Delegate d, Func converter) _converter = converter; } - public override object Invoke(object target, TemplateOptions options) - { - var result = _d((T)target); - return _converter != null ? _converter(result, options) : result; - } - - public override FluidValue InvokeFluidValue(object target, TemplateOptions options) + public override FluidValue Invoke(object target, TemplateOptions options) { var result = _d((T)target); - return _converter != null ? _converter(result, options) : FluidValue.Create(result, options); + return result is null + ? null + : _converter != null + ? _converter(result, options) + : FluidValue.Create(result, options); } } diff --git a/Fluid/Accessors/MethodInfoAccessor.cs b/Fluid/Accessors/MethodInfoAccessor.cs index 723c1657..3f0085cc 100644 --- a/Fluid/Accessors/MethodInfoAccessor.cs +++ b/Fluid/Accessors/MethodInfoAccessor.cs @@ -1,8 +1,9 @@ using System.Reflection; +using Fluid.Values; namespace Fluid.Accessors { - public sealed class MethodInfoAccessor : IMemberAccessor + public sealed class MethodInfoAccessor : MemberAccessor { private readonly MethodInfo _methodInfo; @@ -11,9 +12,9 @@ public MethodInfoAccessor(MethodInfo methodInfo) _methodInfo = methodInfo; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return _methodInfo.Invoke(obj, null); + return CreateValueTask(_methodInfo.Invoke(obj, null), context); } } diff --git a/Fluid/Accessors/PropertyInfoAccessor.cs b/Fluid/Accessors/PropertyInfoAccessor.cs index 888d979b..74dceb03 100644 --- a/Fluid/Accessors/PropertyInfoAccessor.cs +++ b/Fluid/Accessors/PropertyInfoAccessor.cs @@ -4,7 +4,7 @@ namespace Fluid.Accessors; -public sealed class PropertyInfoAccessor : IMemberAccessor, IFluidValueAccessor +public sealed class PropertyInfoAccessor : MemberAccessor { private readonly Invoker _invoker; @@ -65,10 +65,8 @@ public PropertyInfoAccessor(PropertyInfo propertyInfo) _invoker = (Invoker)Activator.CreateInstance(invokerType, [d, converter]); } - public object Get(object obj, string name, TemplateContext ctx) => _invoker.Invoke(obj, ctx.Options); - - FluidValue IFluidValueAccessor.GetFluidValue(object obj, string name, TemplateContext context) - => _invoker.InvokeFluidValue(obj, context.Options); + public override ValueTask GetAsync(object obj, string name, TemplateContext context) + => new(_invoker.Invoke(obj, context.Options)); private static Delegate GetGetter(Type declaringType, string fieldName) { diff --git a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs index 8feccbdf..29ff0a71 100644 --- a/Fluid/Accessors/ReflectionFieldInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionFieldInfoAccessor.cs @@ -3,7 +3,7 @@ namespace Fluid.Accessors; -internal sealed class ReflectionFieldInfoAccessor : IMemberAccessor, IFluidValueAccessor +internal sealed class ReflectionFieldInfoAccessor : MemberAccessor { private readonly FieldInfo _fieldInfo; private readonly TypeCode _typeCode; @@ -16,17 +16,12 @@ public ReflectionFieldInfoAccessor(FieldInfo fieldInfo) _isEnum = fieldInfo.FieldType.IsEnum; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return AccessorValueConverter.Convert(_fieldInfo.GetValue(obj), _typeCode, _isEnum); - } - - public FluidValue GetFluidValue(object obj, string name, TemplateContext context) - { - return AccessorValueConverter.ConvertToFluidValue( + return new(AccessorValueConverter.Convert( _fieldInfo.GetValue(obj), _typeCode, _isEnum, - context.Options); + context.Options)); } } diff --git a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs index c8cfca93..705caef9 100644 --- a/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs +++ b/Fluid/Accessors/ReflectionPropertyInfoAccessor.cs @@ -3,7 +3,7 @@ namespace Fluid.Accessors; -internal sealed class ReflectionPropertyInfoAccessor : IMemberAccessor, IFluidValueAccessor +internal sealed class ReflectionPropertyInfoAccessor : MemberAccessor { private readonly PropertyInfo _propertyInfo; private readonly TypeCode _typeCode; @@ -16,17 +16,12 @@ public ReflectionPropertyInfoAccessor(PropertyInfo propertyInfo) _isEnum = propertyInfo.PropertyType.IsEnum; } - public object Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return AccessorValueConverter.Convert(_propertyInfo.GetValue(obj), _typeCode, _isEnum); - } - - public FluidValue GetFluidValue(object obj, string name, TemplateContext context) - { - return AccessorValueConverter.ConvertToFluidValue( + return new(AccessorValueConverter.Convert( _propertyInfo.GetValue(obj), _typeCode, _isEnum, - context.Options); + context.Options)); } } diff --git a/Fluid/DefaultMemberAccessStrategy.cs b/Fluid/DefaultMemberAccessStrategy.cs index d6e498f7..eaf615f9 100644 --- a/Fluid/DefaultMemberAccessStrategy.cs +++ b/Fluid/DefaultMemberAccessStrategy.cs @@ -17,12 +17,12 @@ public ReflectionCache(object registrationToken) } public object RegistrationToken { get; } - public volatile Dictionary Accessors = []; + public volatile Dictionary Accessors = []; } private static readonly bool _dynamicCodeSupported = IsDynamicCodeSupported(); - private volatile Dictionary _registrations = []; + private volatile Dictionary _registrations = []; private volatile ReflectionCache _reflectionCache; // Only the exact type opts in. A derived strategy may override GetAccessor to resolve from its @@ -38,7 +38,7 @@ public DefaultMemberAccessStrategy() protected internal override object AccessorCacheToken => _accessorCachingSupported ? _registrations : null; - public override IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) + public override MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(name); @@ -69,16 +69,16 @@ public override IMemberAccessor GetAccessor(Type type, string name, StringCompar [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryGetRegisteredAccessor( - Dictionary registrations, + Dictionary registrations, Type type, string name, - out IMemberAccessor accessor) + out MemberAccessor accessor) { return registrations.TryGetValue(new AccessorKey(type, name), out accessor) || registrations.TryGetValue(new AccessorKey(type, "*"), out accessor); } - private static IMemberAccessor GetMemberAccessor(Type type, string name, StringComparer stringComparer) + private static MemberAccessor GetMemberAccessor(Type type, string name, StringComparer stringComparer) { foreach (var propertyInfo in type.GetTypeInfo().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { @@ -173,8 +173,8 @@ private static bool IsDynamicCodeSupported() } // Creates accessors based on base types and interfaces - private static IMemberAccessor GetAccessorUnlikely( - Dictionary registrations, + private static MemberAccessor GetAccessorUnlikely( + Dictionary registrations, Type type, string name, StringComparer stringComparer) @@ -214,7 +214,7 @@ private static IMemberAccessor GetAccessorUnlikely( return null; } - public override void Register(Type type, string name, IMemberAccessor accessor) + public override void Register(Type type, string name, MemberAccessor accessor) { ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(name); @@ -222,7 +222,7 @@ public override void Register(Type type, string name, IMemberAccessor accessor) while (true) { var registrations = _registrations; - var updated = new Dictionary(registrations) + var updated = new Dictionary(registrations) { [new AccessorKey(type, name)] = accessor }; @@ -260,7 +260,7 @@ private ReflectionCache GetReflectionCache(object registrationToken) private static void AddReflectedAccessor( ReflectionCache reflectionCache, ReflectedAccessorKey key, - IMemberAccessor accessor) + MemberAccessor accessor) { while (true) { @@ -271,7 +271,7 @@ private static void AddReflectedAccessor( return; } - var updated = new Dictionary(reflectedAccessors) + var updated = new Dictionary(reflectedAccessors) { [key] = accessor }; diff --git a/Fluid/IMemberAccessor.cs b/Fluid/IMemberAccessor.cs deleted file mode 100644 index bc1ec3f0..00000000 --- a/Fluid/IMemberAccessor.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Fluid -{ - public interface IMemberAccessor - { - object Get(object obj, string name, TemplateContext ctx); - } - - public interface IAsyncMemberAccessor : IMemberAccessor - { - Task GetAsync(object obj, string name, TemplateContext ctx); - } -} diff --git a/Fluid/MemberAccessStrategy.cs b/Fluid/MemberAccessStrategy.cs index 8e098fe0..75076ba2 100644 --- a/Fluid/MemberAccessStrategy.cs +++ b/Fluid/MemberAccessStrategy.cs @@ -2,13 +2,13 @@ namespace Fluid { public abstract class MemberAccessStrategy { - public abstract IMemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer); + public abstract MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer); - public abstract void Register(Type type, string name, IMemberAccessor accessor); + public abstract void Register(Type type, string name, MemberAccessor accessor); /// /// Gets a token identifying the current set of accessors this strategy would return, or - /// null to disable caching. Call sites may remember the + /// null to disable caching. Call sites may remember the /// resolved for a type and name, and re-resolve it only once this token changes. /// /// diff --git a/Fluid/MemberAccessStrategyExtensions.cs b/Fluid/MemberAccessStrategyExtensions.cs index 81c78524..c4153b1f 100644 --- a/Fluid/MemberAccessStrategyExtensions.cs +++ b/Fluid/MemberAccessStrategyExtensions.cs @@ -12,49 +12,49 @@ public static class MemberAccessStrategyExtensions #endif /// - /// Registers a named property when accessing a type using a + /// Registers a named property when accessing a type using a /// to retrieve the value. The name of the property doesn't have to exist on the object. /// /// The type to register. /// The . /// The name of the property to intercept. - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, string name, IMemberAccessor getter) + public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, string name, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, string name, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, string name, MemberAccessor getter) #endif { strategy.Register(typeof(T), name, getter); } /// - /// Registers a type using a to retrieve any of + /// Registers a type using a to retrieve any of /// its property values. /// /// The type to register. /// The . - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, IMemberAccessor getter) + public static void Register<[DynamicallyAccessedMembers(RegisteredMemberTypes)] T>(this MemberAccessStrategy strategy, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, MemberAccessor getter) #endif { strategy.Register("*", getter); } /// - /// Registers a type using a to retrieve any of + /// Registers a type using a to retrieve any of /// its property values. /// /// The . /// The type to register. - /// The instance used to retrieve the value. + /// The instance used to retrieve the value. #if NET5_0_OR_GREATER - public static void Register(this MemberAccessStrategy strategy, [DynamicallyAccessedMembers(RegisteredMemberTypes)] Type type, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, [DynamicallyAccessedMembers(RegisteredMemberTypes)] Type type, MemberAccessor getter) #else - public static void Register(this MemberAccessStrategy strategy, Type type, IMemberAccessor getter) + public static void Register(this MemberAccessStrategy strategy, Type type, MemberAccessor getter) #endif { strategy.Register(type, "*", getter); diff --git a/Fluid/MemberAccessor.cs b/Fluid/MemberAccessor.cs new file mode 100644 index 00000000..14724170 --- /dev/null +++ b/Fluid/MemberAccessor.cs @@ -0,0 +1,41 @@ +using Fluid.Values; + +namespace Fluid +{ + /// + /// Resolves a member to its Fluid value. A null result means the accessor did not handle the name. + /// + public abstract class MemberAccessor + { + public abstract ValueTask GetAsync(object obj, string name, TemplateContext context); + + protected static ValueTask CreateValueTask(T value, TemplateContext context) + { + return new(value is null ? null : FluidValue.Create(value, context.Options)); + } + + protected static async ValueTask CreateValueTask(Task task, TemplateContext context) + { + var value = await task.ConfigureAwait(false); + return value is null ? null : FluidValue.Create(value, context.Options); + } + + protected static async ValueTask CreateValueTask(ValueTask task, TemplateContext context) + { + var value = await task.ConfigureAwait(false); + return value is null ? null : FluidValue.Create(value, context.Options); + } + + protected static async ValueTask CreateValueTask(Task task, TemplateContext context) + { + await task.ConfigureAwait(false); + return null; + } + + protected static async ValueTask CreateValueTask(ValueTask task, TemplateContext context) + { + await task.ConfigureAwait(false); + return null; + } + } +} diff --git a/Fluid/NullMemberAccessor.cs b/Fluid/NullMemberAccessor.cs index 66eb68f0..255d1943 100644 --- a/Fluid/NullMemberAccessor.cs +++ b/Fluid/NullMemberAccessor.cs @@ -1,17 +1,19 @@ -namespace Fluid +using Fluid.Values; + +namespace Fluid { - public sealed class NullMemberAccessor : IMemberAccessor + public sealed class NullMemberAccessor : MemberAccessor { - public static readonly IMemberAccessor Instance = new NullMemberAccessor(); + public static readonly MemberAccessor Instance = new NullMemberAccessor(); private NullMemberAccessor() { } - object IMemberAccessor.Get(object obj, string name, TemplateContext ctx) + public override ValueTask GetAsync(object obj, string name, TemplateContext context) { - return null; + return new((FluidValue)null); } } } diff --git a/Fluid/Values/ObjectValueBase.cs b/Fluid/Values/ObjectValueBase.cs index fe3b2527..4483b620 100644 --- a/Fluid/Values/ObjectValueBase.cs +++ b/Fluid/Values/ObjectValueBase.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.Text; using System.Text.Encodings.Web; -using Fluid.Accessors; using Fluid.Utils; namespace Fluid.Values @@ -40,7 +39,7 @@ public override bool Equals(FluidValue other) } /// - /// A resolved remembered by a single call site, so that repeatedly + /// A resolved remembered by a single call site, so that repeatedly /// reading the same member off the same type (a loop body, typically) doesn't hash the member /// name into the strategy's dictionary on every iteration. /// @@ -59,7 +58,7 @@ internal sealed class AccessorCacheEntry /// public static readonly AccessorCacheEntry Disabled = new(null, null, null, null, 0); - public AccessorCacheEntry(object token, Type type, StringComparer comparer, IMemberAccessor accessor, int misses) + public AccessorCacheEntry(object token, Type type, StringComparer comparer, MemberAccessor accessor, int misses) { Token = token; Type = type; @@ -71,7 +70,7 @@ public AccessorCacheEntry(object token, Type type, StringComparer comparer, IMem public readonly object Token; public readonly Type Type; public readonly StringComparer Comparer; - public readonly IMemberAccessor Accessor; + public readonly MemberAccessor Accessor; /// /// How often this site had to re-resolve. Counted on the entry rather than the segment so @@ -98,7 +97,7 @@ internal ValueTask GetValueAsync(string name, TemplateContext contex return GetValueAsync(name, context, nameHasDot, GetAccessorCached(name, context, ref cache)); } - private IMemberAccessor GetAccessorCached(string name, TemplateContext context, ref AccessorCacheEntry cache) + private MemberAccessor GetAccessorCached(string name, TemplateContext context, ref AccessorCacheEntry cache) { var type = Value.GetType(); var strategy = context.Options.MemberAccessStrategy; @@ -146,22 +145,24 @@ private IMemberAccessor GetAccessorCached(string name, TemplateContext context, return accessor; } - private ValueTask GetValueAsync(string name, TemplateContext context, bool nameHasDot, IMemberAccessor accessor) + private ValueTask GetValueAsync(string name, TemplateContext context, bool nameHasDot, MemberAccessor accessor) { if (nameHasDot) { if (accessor != null) { - if (accessor is IAsyncMemberAccessor asyncAccessor) + var task = accessor.GetAsync(Value, name, context); + + if (!task.IsCompletedSuccessfully) { - return Awaited(asyncAccessor, Value, name, context); + return AwaitedDirect(task, name, context); } - var directValue = accessor.Get(Value, name, context); + var directValue = task.Result; if (directValue != null) { - return FluidValue.Create(directValue, context.Options); + return new ValueTask(directValue); } } @@ -170,17 +171,13 @@ private ValueTask GetValueAsync(string name, TemplateContext context if (accessor != null) { - if (accessor is IAsyncMemberAccessor asyncAccessor) - { - return Awaited(asyncAccessor, Value, name, context); - } - - if (accessor is IFluidValueAccessor fluidValueAccessor) + var task = accessor.GetAsync(Value, name, context); + if (task.IsCompletedSuccessfully) { - return fluidValueAccessor.GetFluidValue(Value, name, context); + return new ValueTask(task.Result ?? NilValue.Instance); } - return Create(accessor.Get(Value, name, context), context.Options); + return Awaited(task); } if (context.Options.StrictVariables) @@ -194,13 +191,18 @@ private ValueTask GetValueAsync(string name, TemplateContext context return NilValue.Instance; - static async ValueTask Awaited( - IAsyncMemberAccessor asyncAccessor, - object value, - string n, + static async ValueTask Awaited(ValueTask task) + { + return await task ?? NilValue.Instance; + } + + async ValueTask AwaitedDirect( + ValueTask task, + string memberName, TemplateContext ctx) { - return Create(await asyncAccessor.GetAsync(value, n, ctx), ctx.Options); + var directValue = await task; + return directValue ?? await GetNestedValueAsync(memberName, ctx); } } @@ -208,6 +210,7 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon { var members = name.Split(MemberSeparators); var target = Value; + FluidValue value = null; List segments = context.Undefined is not null ? [] : null; foreach (var prop in members) @@ -237,17 +240,11 @@ private async ValueTask GetNestedValueAsync(string name, TemplateCon return UndefinedValue.Instance; } - if (accessor is IAsyncMemberAccessor asyncAccessor) - { - target = await asyncAccessor.GetAsync(target, prop, context); - } - else - { - target = accessor.Get(target, prop, context); - } + value = await accessor.GetAsync(target, prop, context); + target = value?.ToObjectValue(); } - return Create(target, context.Options); + return value ?? NilValue.Instance; } public override ValueTask GetIndexAsync(FluidValue index, TemplateContext context) From b2c368d136c746aa3f95febd93d01b52edda0b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:47:32 -0700 Subject: [PATCH 3/5] Remove identifier canonicalization Restore direct identifier string creation during parsing and remove the parse-local canonicalization state and its reference-identity test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a5b2c42-bcdc-4c1f-b0c4-26e49441e90b --- Fluid.Tests/ParserTests.cs | 21 --------- Fluid/FluidParser.cs | 10 +---- Fluid/Parser/FluidParseContext.cs | 75 ------------------------------- 3 files changed, 2 insertions(+), 104 deletions(-) diff --git a/Fluid.Tests/ParserTests.cs b/Fluid.Tests/ParserTests.cs index ebbbd4b0..86680a16 100644 --- a/Fluid.Tests/ParserTests.cs +++ b/Fluid.Tests/ParserTests.cs @@ -102,27 +102,6 @@ public void ShouldParseForTag() Assert.IsType(statements.ElementAt(0)); } - [Fact] - public void ShouldCanonicalizeIdentifiersWithinTemplate() - { - var statements = Parse( - "{% assign product = nil %}" + - "{% for product in products %}{{ product.name }}{{ product.price }}{% endfor %}"); - - var assign = Assert.IsType(statements[0]); - var forStatement = Assert.IsType(statements[1]); - var source = Assert.IsType(forStatement.Source); - var nameOutput = Assert.IsType(forStatement.Statements[0]); - var name = Assert.IsType(nameOutput.Expression); - var priceOutput = Assert.IsType(forStatement.Statements[1]); - var price = Assert.IsType(priceOutput.Expression); - - Assert.Same(assign.Identifier, forStatement.Identifier); - Assert.Same(forStatement.Identifier, ((IdentifierSegment)name.Segments[0]).Identifier); - Assert.Same(forStatement.Identifier, ((IdentifierSegment)price.Segments[0]).Identifier); - Assert.Equal("products", ((IdentifierSegment)source.Segments[0]).Identifier); - } - [Fact] public void ShouldParseForElseTag() { diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index 43bda550..ccc86fd2 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -95,14 +95,8 @@ public FluidParser(FluidParserOptions parserOptions) TagEnd = NoInlineTagEnd; } - Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestionMark)) - .Then((context, x) => context is FluidParseContext fluidContext - ? fluidContext.CanonicalizeIdentifier(x.ToString()) - : x.ToString()); - VariableSignature = SkipWhiteSpace(new VariableSignatureParser()) - .Then((context, x) => context is FluidParseContext fluidContext - ? fluidContext.CanonicalizeIdentifier(x.ToString()) - : x.ToString()); + Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestionMark)).Then(x => x.ToString()); + VariableSignature = SkipWhiteSpace(new VariableSignatureParser()).Then(x => x.ToString()); String.Name = "String"; diff --git a/Fluid/Parser/FluidParseContext.cs b/Fluid/Parser/FluidParseContext.cs index d2092509..58f53de7 100644 --- a/Fluid/Parser/FluidParseContext.cs +++ b/Fluid/Parser/FluidParseContext.cs @@ -6,85 +6,10 @@ namespace Fluid.Parser { public class FluidParseContext : ParseContext { - private const int InlineIdentifierCapacity = 8; - - private string _identifier0, _identifier1, _identifier2, _identifier3; - private string _identifier4, _identifier5, _identifier6, _identifier7; - private int _identifierCount; - private Dictionary _identifiers; - public FluidParseContext(string text) : base(new Scanner(text)) { } - // Keep identifier references local to this parse so repeated scope names can use reference equality. - internal string CanonicalizeIdentifier(string identifier) - { - if (_identifiers is not null) - { - if (_identifiers.TryGetValue(identifier, out var canonical)) - { - return canonical; - } - - _identifiers.Add(identifier, identifier); - return identifier; - } - - for (var i = 0; i < _identifierCount; i++) - { - var canonical = GetIdentifier(i); - - if (string.Equals(identifier, canonical, StringComparison.Ordinal)) - { - return canonical; - } - } - - if (_identifierCount < InlineIdentifierCapacity) - { - SetIdentifier(_identifierCount++, identifier); - return identifier; - } - - _identifiers = new Dictionary(StringComparer.Ordinal); - for (var i = 0; i < InlineIdentifierCapacity; i++) - { - var canonical = GetIdentifier(i); - _identifiers.Add(canonical, canonical); - } - - _identifiers.Add(identifier, identifier); - return identifier; - } - - private string GetIdentifier(int index) => index switch - { - 0 => _identifier0, - 1 => _identifier1, - 2 => _identifier2, - 3 => _identifier3, - 4 => _identifier4, - 5 => _identifier5, - 6 => _identifier6, - _ => _identifier7, - }; - - private void SetIdentifier(int index, string identifier) - { - switch (index) - { - case 0: _identifier0 = identifier; break; - case 1: _identifier1 = identifier; break; - case 2: _identifier2 = identifier; break; - case 3: _identifier3 = identifier; break; - case 4: _identifier4 = identifier; break; - case 5: _identifier5 = identifier; break; - case 6: _identifier6 = identifier; break; - default: _identifier7 = identifier; break; - } - } - public string PreviousRenderTag { get; set; } public TextSpanStatement PreviousTextSpanStatement { get; set; } public bool StripNextTextSpanStatement { get; set; } From c00764fbaa2b3f78f851a414c6e82d93461dab41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:19:54 -0700 Subject: [PATCH 4/5] Document custom member accessors Describe migration from the removed accessor interfaces to MemberAccessor, including conversion helpers, nil semantics, and exact-name registration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a5b2c42-bcdc-4c1f-b0c4-26e49441e90b --- Fluid/MemberAccessor.cs | 12 ++++++++++++ README.md | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Fluid/MemberAccessor.cs b/Fluid/MemberAccessor.cs index 14724170..02247758 100644 --- a/Fluid/MemberAccessor.cs +++ b/Fluid/MemberAccessor.cs @@ -5,8 +5,20 @@ namespace Fluid /// /// Resolves a member to its Fluid value. A null result means the accessor did not handle the name. /// + /// + /// Use the protected CreateValueTask overloads to convert CLR values and asynchronous + /// results with the value converters configured on the current template context. Return + /// for a Liquid nil value. + /// public abstract class MemberAccessor { + /// + /// Resolves a member on an object. + /// + /// The object that owns the member. + /// The member name requested by the template. + /// The current template context. + /// The resolved Fluid value, or null when the accessor did not handle the name. public abstract ValueTask GetAsync(object obj, string name, TemplateContext context); protected static ValueTask CreateValueTask(T value, TemplateContext context) diff --git a/README.md b/README.md index b01ab4f1..0dee113c 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ context.SetValue("temporary", value); Fluid works when targeting NativeAOT and trimmed deployments. - If dynamic code is not supported at runtime, Fluid automatically switches to reflection-based member accessors. -- Existing `MemberAccessStrategy.Register` APIs are preserved. +- Delegate-based `MemberAccessStrategy.Register` APIs remain available. - No interceptor setup is required. ### Recommended usage when targeting NativeAOT @@ -208,6 +208,29 @@ The generated registrations are instance-scoped and are applied automatically to options.MemberAccessStrategy.Register((product, name) => product.Name); ``` +### Custom member accessors + +Custom accessors derive from `MemberAccessor` and return a `ValueTask` directly. The previous `IMemberAccessor` and `IAsyncMemberAccessor` interfaces are no longer used. + +```csharp +private sealed class ProductDisplayNameAccessor : MemberAccessor +{ + public override ValueTask GetAsync( + object obj, + string name, + TemplateContext context) + { + return CreateValueTask(((Product)obj).Name, context); + } +} + +options.MemberAccessStrategy.Register( + "display_name", + new ProductDisplayNameAccessor()); +``` + +The protected `CreateValueTask` overloads convert synchronous, `Task`, and `ValueTask` results using the `TemplateOptions.ValueConverters` configured for the current context. Return `NilValue.Instance` for a Liquid `nil` value; a null `FluidValue` is reserved as the accessor's not-handled result. + Alternatively, explicit profile methods can apply generated registrations to any `TemplateOptions` instance: ```csharp From 7ac870da5023f41058c3988618b1414d22ab6a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:21:00 -0700 Subject: [PATCH 5/5] Remove accessor migration wording Document only the current MemberAccessor API without describing compatibility or removed interfaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a5b2c42-bcdc-4c1f-b0c4-26e49441e90b --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0dee113c..bd33485b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ context.SetValue("temporary", value); Fluid works when targeting NativeAOT and trimmed deployments. - If dynamic code is not supported at runtime, Fluid automatically switches to reflection-based member accessors. -- Delegate-based `MemberAccessStrategy.Register` APIs remain available. +- Runtime `MemberAccessStrategy.Register` APIs are available for custom mappings. - No interceptor setup is required. ### Recommended usage when targeting NativeAOT @@ -210,7 +210,7 @@ options.MemberAccessStrategy.Register((product, name) => produc ### Custom member accessors -Custom accessors derive from `MemberAccessor` and return a `ValueTask` directly. The previous `IMemberAccessor` and `IAsyncMemberAccessor` interfaces are no longer used. +Custom accessors derive from `MemberAccessor` and return a `ValueTask` directly. ```csharp private sealed class ProductDisplayNameAccessor : MemberAccessor