Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
96 changes: 9 additions & 87 deletions Fluid.SourceGenerator/MemberAccessorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -480,12 +480,12 @@ private static string GetAccessibilityKeyword(Accessibility accessibility)

private static void AppendAccessor(StringBuilder source, string accessorName, string typeExpression, List<MemberAccess> 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<global::Fluid.Values.FluidValue> 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)
Expand All @@ -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<object> 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(" }");
}
Expand All @@ -572,7 +526,7 @@ private static List<MemberAccess> 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))
Expand All @@ -592,7 +546,7 @@ private static List<MemberAccess> 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))
Expand All @@ -612,7 +566,7 @@ private static List<MemberAccess> 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;
Expand Down Expand Up @@ -701,29 +655,6 @@ private static IEnumerable<ISymbol> 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<string> usedAccessorNames)
{
var baseName = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
Expand Down Expand Up @@ -797,16 +728,7 @@ private sealed record GeneratedOptionsTypeRegistration(
INamedTypeSymbol OptionsType,
ImmutableArray<AccessorRegistration> 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 = """
// <auto-generated />
Expand Down
43 changes: 36 additions & 7 deletions Fluid.Tests/MemberAccessStrategyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -63,22 +63,50 @@ 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]
public void BaseRegistrationChangesShouldInvalidateDerivedAccessors()
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 async Task BaseRegistrationChangesShouldInvalidateDerivedAccessors()
{
var strategy = new DefaultMemberAccessStrategy();
strategy.Register<Class1, object>((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<Class1, object>((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]
Expand Down Expand Up @@ -404,7 +432,7 @@ void ITemplateOptionsMemberAccessorRegistrar.RegisterMemberAccessors(TemplateOpt
}
}

public sealed class FixedMemberAccessor : IMemberAccessor
public sealed class FixedMemberAccessor : MemberAccessor
{
private readonly string _value;

Expand All @@ -413,6 +441,7 @@ public FixedMemberAccessor(string value)
_value = value;
}

public object Get(object obj, string name, TemplateContext ctx) => _value;
public override ValueTask<FluidValue> GetAsync(object obj, string name, TemplateContext context)
=> CreateValueTask(_value, context);
}
}
9 changes: 4 additions & 5 deletions Fluid.Tests/MemberAccessorGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion Fluid.Tests/RenderCacheInvalidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;

Expand Down
13 changes: 9 additions & 4 deletions Fluid/Accessors/AccessorValueConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +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),
};
}
}
15 changes: 6 additions & 9 deletions Fluid/Accessors/AsyncDelegateAccessorOfT.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace Fluid.Accessors
using Fluid.Values;

namespace Fluid.Accessors
{
public class AsyncDelegateAccessor<T, TResult> : IAsyncMemberAccessor
public class AsyncDelegateAccessor<T, TResult> : MemberAccessor
{
private readonly Func<T, string, TemplateContext, Task<TResult>> _getter;

Expand All @@ -9,19 +11,14 @@ public AsyncDelegateAccessor(Func<T, string, TemplateContext, Task<TResult>> get
_getter = getter;
}

public object Get(object obj, string name, TemplateContext ctx)
{
throw new NotImplementedException();
}

public Task<TResult> GetAsync(T obj, string name, TemplateContext ctx)
{
return _getter(obj, name, ctx);
}

async Task<object> IAsyncMemberAccessor.GetAsync(object obj, string name, TemplateContext ctx)
public override ValueTask<FluidValue> GetAsync(object obj, string name, TemplateContext context)
{
return await _getter((T)obj, name, ctx);
return CreateValueTask(_getter((T)obj, name, context), context);
}
}
}
10 changes: 6 additions & 4 deletions Fluid/Accessors/DelegateAccessorOfT.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace Fluid.Accessors
using Fluid.Values;

namespace Fluid.Accessors
{
public class DelegateAccessor<T, TResult> : IMemberAccessor
public class DelegateAccessor<T, TResult> : MemberAccessor
{
private readonly Func<T, string, TemplateContext, TResult> _getter;

Expand All @@ -9,9 +11,9 @@ public DelegateAccessor(Func<T, string, TemplateContext, TResult> getter)
_getter = getter;
}

object IMemberAccessor.Get(object obj, string name, TemplateContext ctx)
public override ValueTask<FluidValue> GetAsync(object obj, string name, TemplateContext context)
{
return _getter((T)obj, name, ctx);
return CreateValueTask(_getter((T)obj, name, context), context);
}
}
}
8 changes: 3 additions & 5 deletions Fluid/Accessors/FieldInfoAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Fluid.Accessors
{
public sealed class FieldInfoAccessor : IMemberAccessor
public sealed class FieldInfoAccessor : MemberAccessor
{
private readonly Invoker _invoker;

Expand Down Expand Up @@ -48,10 +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);
}
public override ValueTask<FluidValue> GetAsync(object obj, string name, TemplateContext context)
=> new(_invoker?.Invoke(obj, context.Options));

private static Delegate GetGetter(FieldInfo field)
{
Expand Down
Loading