diff --git a/Fluid.SourceGenerator/MemberAccessorGenerator.cs b/Fluid.SourceGenerator/MemberAccessorGenerator.cs index 1f468508..cc3831c1 100644 --- a/Fluid.SourceGenerator/MemberAccessorGenerator.cs +++ b/Fluid.SourceGenerator/MemberAccessorGenerator.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; namespace Fluid.SourceGenerator; @@ -10,6 +11,7 @@ namespace Fluid.SourceGenerator; public sealed class MemberAccessorGenerator : IIncrementalGenerator { private const string RegisterAttributeType = "Fluid.FluidRegisterAttribute"; + private const string TemplateContextType = "Fluid.TemplateContext"; private const string TemplateOptionsType = "Fluid.TemplateOptions"; private static readonly SymbolDisplayFormat TypeExpressionFormat = SymbolDisplayFormat.FullyQualifiedFormat @@ -50,8 +52,25 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static candidate => candidate is not null) .Select(static (candidate, _) => candidate!); - var combined = context.CompilationProvider.Combine(profileMethods.Collect()).Combine(optionsTypes.Collect()); - context.RegisterSourceOutput(combined, static (sourceContext, source) => Execute(sourceContext, source.Left.Right, source.Right)); + var inferredModelTypes = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => + node is BaseObjectCreationExpressionSyntax { ArgumentList.Arguments.Count: 2 }, + transform: static (syntaxContext, _) => GetTemplateContextModelType(syntaxContext)) + .Where(static candidate => candidate is not null) + .Select(static (candidate, _) => candidate!); + + var combined = context.CompilationProvider + .Combine(profileMethods.Collect()) + .Combine(optionsTypes.Collect()) + .Combine(inferredModelTypes.Collect()); + + context.RegisterSourceOutput(combined, static (sourceContext, source) => + Execute( + sourceContext, + source.Left.Left.Left, + source.Left.Left.Right, + source.Left.Right, + source.Right)); } private static ProfileMethodCandidate? GetProfileMethod(GeneratorSyntaxContext context) @@ -116,6 +135,60 @@ public void Initialize(IncrementalGeneratorInitializationContext context) typeSyntax.GetLocation()); } + private static ITypeSymbol? GetTemplateContextModelType(GeneratorSyntaxContext context) + { + if (context.SemanticModel.GetOperation(context.Node) is not IObjectCreationOperation creation || + creation.Constructor is not { Parameters.Length: 2 } constructor || + !string.Equals(constructor.ContainingType.ToDisplayString(), TemplateContextType, StringComparison.Ordinal) || + !string.Equals(constructor.Parameters[1].Type.ToDisplayString(), TemplateOptionsType, StringComparison.Ordinal)) + { + return null; + } + + var modelArgument = creation.Arguments.FirstOrDefault(static argument => argument.Parameter?.Ordinal == 0); + var optionsArgument = creation.Arguments.FirstOrDefault(static argument => argument.Parameter?.Ordinal == 1); + if (modelArgument is null || optionsArgument is null || IsTemplateOptionsDefault(optionsArgument.Value)) + { + return null; + } + + IOperation value = modelArgument.Value; + while (value is IConversionOperation { IsImplicit: true } conversion) + { + value = conversion.Operand; + } + + var modelType = value.Type; + if (modelType is null || + modelType.SpecialType == SpecialType.System_Object || + modelType.TypeKind is TypeKind.Dynamic or TypeKind.Error or TypeKind.TypeParameter || + !CanGenerateAccessor(modelType)) + { + return null; + } + + return modelType.IsReferenceType + ? modelType.WithNullableAnnotation(NullableAnnotation.NotAnnotated) + : modelType; + } + + private static bool IsTemplateOptionsDefault(IOperation operation) + { + while (operation is IConversionOperation { IsImplicit: true } conversion) + { + operation = conversion.Operand; + } + + return operation is IFieldReferenceOperation + { + Field: + { + IsStatic: true, + Name: "Default" + } field + } && string.Equals(field.ContainingType.ToDisplayString(), TemplateOptionsType, StringComparison.Ordinal); + } + private static ImmutableArray GetRegisteredTypes(ISymbol symbol) { var registerAttributes = symbol.GetAttributes() @@ -152,9 +225,14 @@ private static ImmutableArray GetRegisteredTypes(ISymbol symbol) return registeredTypes.ToImmutableArray(); } - private static void Execute(SourceProductionContext context, ImmutableArray methodCandidates, ImmutableArray optionsTypeCandidates) + private static void Execute( + SourceProductionContext context, + Compilation compilation, + ImmutableArray methodCandidates, + ImmutableArray optionsTypeCandidates, + ImmutableArray inferredModelTypes) { - if (methodCandidates.IsDefaultOrEmpty && optionsTypeCandidates.IsDefaultOrEmpty) + if (methodCandidates.IsDefaultOrEmpty && optionsTypeCandidates.IsDefaultOrEmpty && inferredModelTypes.IsDefaultOrEmpty) { return; } @@ -195,7 +273,24 @@ private static void Execute(SourceProductionContext context, ImmutableArray(StringComparer.Ordinal); + var supportsModuleInitializers = compilation.SyntaxTrees.FirstOrDefault()?.Options is CSharpParseOptions + { + LanguageVersion: >= LanguageVersion.CSharp9 + }; + + if (supportsModuleInitializers) + { + foreach (var inferredModelType in inferredModelTypes) + { + var typeExpression = inferredModelType.ToDisplayString(TypeExpressionFormat); + allRegisteredTypes[typeExpression] = inferredModelType; + inferredTypeExpressions.Add(typeExpression); + } + } + + if ((validMethods.Count == 0 && validOptionsTypes.Count == 0 && inferredTypeExpressions.Count == 0) || + allRegisteredTypes.Count == 0) { return; } @@ -249,7 +344,14 @@ private static void Execute(SourceProductionContext context, ImmutableArray x.OptionsType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), StringComparer.Ordinal) .ToList(); - if (methodRegistrations.Count == 0 && optionsTypeRegistrations.Count == 0) + var inferredAccessors = inferredTypeExpressions + .Where(accessorsByType.ContainsKey) + .OrderBy(static x => x, StringComparer.Ordinal) + .Select(typeExpression => accessorsByType[typeExpression]) + .Where(static accessor => accessor.Members.Any(static member => member.CanInfer)) + .ToImmutableArray(); + + if (methodRegistrations.Count == 0 && optionsTypeRegistrations.Count == 0 && inferredAccessors.IsDefaultOrEmpty) { return; } @@ -267,6 +369,31 @@ private static void Execute(SourceProductionContext context, ImmutableArray accessors) + { + source.AppendLine(" internal static class InferredMemberAccessorRegistration"); + source.AppendLine(" {"); + source.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + source.AppendLine(" internal static void Register()"); + source.AppendLine(" {"); + + foreach (var accessor in accessors) + { + for (var i = 0; i < accessor.Members.Count; i++) + { + var member = accessor.Members[i]; + if (!member.CanInfer) + { + continue; + } + + source.Append(" global::Fluid.DefaultMemberAccessStrategy.RegisterSourceGeneratedAccessor(typeof(") + .Append(accessor.TypeExpression) + .Append("), new ") + .Append(GetInferredAccessorName(accessor.AccessorName, i)) + .Append("(), new string[] { \"") + .Append(member.Name) + .AppendLine("\" });"); + } + } + + source.AppendLine(" }"); + source.AppendLine(" }"); + } + + private static void AppendDirectAccessor( + StringBuilder source, + string accessorName, + string typeExpression, + string expression) + { + source.Append(" internal sealed class ").Append(accessorName).AppendLine(" : global::Fluid.MemberAccessor"); + source.AppendLine(" {"); + 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.Append(" return CreateValueTask(").Append(expression).AppendLine(", context);"); + source.AppendLine(" }"); + source.AppendLine(" }"); + } + + private static string GetInferredAccessorName(string accessorName, int memberIndex) + => accessorName + "_Inferred" + memberIndex; + private static bool TryValidateProfileMethod(ProfileMethodCandidate candidate, SourceProductionContext context, out ProfileMethodRegistration registration) { registration = default!; @@ -526,7 +710,11 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(property.Name, expression)); + members.Add(new MemberAccess( + property.Name, + expression, + IsMethod: false, + CanInfer: CanInferMemberType(property.Type))); } foreach (var field in EnumerateFields(typeSymbol)) @@ -546,7 +734,11 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}" : $"typed.{memberName}"; - members.Add(new MemberAccess(field.Name, expression)); + members.Add(new MemberAccess( + field.Name, + expression, + IsMethod: false, + CanInfer: CanInferMemberType(field.Type))); } foreach (var method in EnumerateMethods(typeSymbol)) @@ -566,7 +758,7 @@ private static List GetMembers(ITypeSymbol typeSymbol) ? $"{typeSymbol.ToDisplayString(TypeExpressionFormat)}.{memberName}()" : $"typed.{memberName}()"; - members.Add(new MemberAccess(method.Name, expression)); + members.Add(new MemberAccess(method.Name, expression, IsMethod: true, CanInfer: false)); } return members; @@ -576,7 +768,9 @@ private static IEnumerable EnumerateProperties(ITypeSymbol type { foreach (var symbol in EnumerateMembers(typeSymbol).OfType()) { - if (symbol.IsIndexer || symbol.GetMethod is null) + if (symbol.IsIndexer || + symbol.GetMethod is null || + symbol.GetMethod.DeclaredAccessibility != Accessibility.Public) { continue; } @@ -655,6 +849,71 @@ private static IEnumerable EnumerateMembers(ITypeSymbol typeSymbol) } } + private static bool CanInferMemberType(ITypeSymbol typeSymbol) + { + if (typeSymbol is not INamedTypeSymbol namedType || + !string.Equals(namedType.ContainingNamespace.ToDisplayString(), "System.Threading.Tasks", StringComparison.Ordinal)) + { + return true; + } + + return namedType.Name switch + { + "Task" => namedType.IsGenericType, + "ValueTask" => false, + _ => true + }; + } + + private static bool CanGenerateAccessor(ITypeSymbol typeSymbol) + { + if (!typeSymbol.CanBeReferencedByName) + { + return false; + } + + return typeSymbol switch + { + IArrayTypeSymbol arrayType => CanGenerateAccessor(arrayType.ElementType), + IPointerTypeSymbol pointerType => CanGenerateAccessor(pointerType.PointedAtType), + INamedTypeSymbol namedType => CanGenerateAccessor(namedType), + _ => true + }; + } + + private static bool CanGenerateAccessor(INamedTypeSymbol typeSymbol) + { + if (typeSymbol.IsAnonymousType || + typeSymbol.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal)) + { + return false; + } + + foreach (var syntaxReference in typeSymbol.DeclaringSyntaxReferences) + { + if (syntaxReference.GetSyntax() is TypeDeclarationSyntax typeSyntax && + typeSyntax.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.FileKeyword))) + { + return false; + } + } + + if (typeSymbol.ContainingType is not null && !CanGenerateAccessor(typeSymbol.ContainingType)) + { + return false; + } + + foreach (var typeArgument in typeSymbol.TypeArguments) + { + if (!CanGenerateAccessor(typeArgument)) + { + return false; + } + } + + return true; + } + private static string CreateAccessorName(ITypeSymbol typeSymbol, HashSet usedAccessorNames) { var baseName = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); @@ -728,7 +987,7 @@ private sealed record GeneratedOptionsTypeRegistration( INamedTypeSymbol OptionsType, ImmutableArray Accessors); - private sealed record MemberAccess(string Name, string Expression); + private sealed record MemberAccess(string Name, string Expression, bool IsMethod, bool CanInfer); private static readonly string AttributeSource = """ // @@ -747,4 +1006,15 @@ public FluidRegisterAttribute(global::System.Type type) } } """; + + private static readonly string ModuleInitializerAttributeSource = """ + + namespace System.Runtime.CompilerServices + { + [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)] + internal sealed class ModuleInitializerAttribute : global::System.Attribute + { + } + } + """; } diff --git a/Fluid.Tests/Fluid.Tests.csproj b/Fluid.Tests/Fluid.Tests.csproj index 196b97f4..abe2b652 100644 --- a/Fluid.Tests/Fluid.Tests.csproj +++ b/Fluid.Tests/Fluid.Tests.csproj @@ -24,7 +24,9 @@ - + diff --git a/Fluid.Tests/MemberAccessStrategyTests.cs b/Fluid.Tests/MemberAccessStrategyTests.cs index 4ca1ec12..f7bb9ae1 100644 --- a/Fluid.Tests/MemberAccessStrategyTests.cs +++ b/Fluid.Tests/MemberAccessStrategyTests.cs @@ -392,6 +392,79 @@ public void ShouldAllowRuntimeRegistrationsToOverrideGeneratedMemberAccessors() Assert.Equal("runtime", template.Render(new TemplateContext(model, options))); } + + [Fact] + public void ShouldUseGeneratedMemberAccessorInferredFromTemplateContextConstructor() + { + var options = new TemplateOptions(); + var model = new InferredModel(); + var context = new TemplateContext(model, options); + + var accessor = options.MemberAccessStrategy.GetAccessor( + typeof(InferredModel), + nameof(InferredModel.Inferred), + options.ModelNamesComparer); + + Assert.Equal("Fluid.SourceGenerated", accessor.GetType().Namespace); + Assert.Equal("inferred", _parser.Parse("{{ Inferred }}").Render(context)); + Assert.Equal("", _parser.Parse("{{ NotAProperty }}").Render(context)); + Assert.Null(options.MemberAccessStrategy.GetAccessor( + typeof(InferredModel), + nameof(InferredModel.NotAProperty), + options.ModelNamesComparer)); + } + + [Fact] + public void InferredAccessorShouldPreserveBaseTypeRegistrationFallback() + { + var options = new TemplateOptions(); + options.MemberAccessStrategy.Register( + static (_, name) => name == "Custom" ? "custom" : null); + var context = new TemplateContext(new InferredModel(), options); + + Assert.Equal("custom", _parser.Parse("{{ Custom }}").Render(context)); + } + + [Fact] + public void ShouldNotActivateInferredAccessorOnTemplateOptionsDefault() + { + var options = TemplateOptions.Default; + var context = new TemplateContext(new DefaultInferredModel(), options); + var accessor = options.MemberAccessStrategy.GetAccessor( + typeof(DefaultInferredModel), + nameof(DefaultInferredModel.Value), + options.ModelNamesComparer); + + Assert.Equal("Fluid.Accessors", accessor.GetType().Namespace); + Assert.Equal("default", _parser.Parse("{{ Value }}").Render(context)); + } + + [Fact] + public void InferredAccessorShouldUseConfiguredValueConverters() + { + var options = new TemplateOptions(); + options.ValueConverters.Add(static value => value is int ? "converted" : null); + var context = new TemplateContext(new InferredModel(), options); + + Assert.Equal("converted", _parser.Parse("{{ Count }}").Render(context)); + } + + [Fact] + public void InferredAccessorShouldPreserveNonGenericAsyncMemberBehavior() + { + var options = new TemplateOptions(); + _ = new TemplateContext(new InferredModel(), options); + + Assert.Equal("Fluid.Accessors", options.MemberAccessStrategy.GetAccessor( + typeof(InferredModel), + nameof(InferredModel.PlainTask), + options.ModelNamesComparer).GetType().Namespace); + Assert.Equal("Fluid.Accessors", options.MemberAccessStrategy.GetAccessor( + typeof(InferredModel), + nameof(InferredModel.ValueTask), + options.ModelNamesComparer).GetType().Namespace); + } + } public class ModelWithStaticNull @@ -424,6 +497,24 @@ public sealed class GeneratedModel public string Generated { get; set; } = "model"; } + public class InferredModelBase + { + } + + public sealed class InferredModel : InferredModelBase + { + public string Inferred { get; set; } = "inferred"; + public int Count { get; set; } = 42; + public Task PlainTask { get; set; } = Task.CompletedTask; + public ValueTask ValueTask { get; set; } + public string NotAProperty() => "method"; + } + + public sealed class DefaultInferredModel + { + public string Value => "default"; + } + public sealed class GeneratedTemplateOptions : TemplateOptions, ITemplateOptionsMemberAccessorRegistrar { void ITemplateOptionsMemberAccessorRegistrar.RegisterMemberAccessors(TemplateOptions options) diff --git a/Fluid.Tests/MemberAccessorGeneratorTests.cs b/Fluid.Tests/MemberAccessorGeneratorTests.cs index aecd285e..fbdd8977 100644 --- a/Fluid.Tests/MemberAccessorGeneratorTests.cs +++ b/Fluid.Tests/MemberAccessorGeneratorTests.cs @@ -109,6 +109,87 @@ public partial class PublicTemplateOptions : TemplateOptions Assert.Contains("strategy.Register(typeof(global::Address), \"*\", new global::Fluid.SourceGenerated.Address_GeneratedMemberAccessor());", generated); } + [Fact] + public void ShouldInferModelTypeFromTemplateContextWithCustomOptions() + { + var source = """ + using Fluid; + + public class Person + { + public string FirstName { get; set; } = ""; + public string Hidden { private get; set; } = ""; + public string Method() => ""; + public System.Threading.Tasks.Task PlainTask { get; set; } = System.Threading.Tasks.Task.CompletedTask; + public System.Threading.Tasks.ValueTask ValueTask { get; set; } + } + + public static class ContextFactory + { + public static TemplateContext Create(Person person, TemplateOptions options) + => new TemplateContext(person, options); + } + """; + + var generated = RunGenerator(source); + + Assert.Contains("internal sealed class Person_GeneratedMemberAccessor", generated); + Assert.Contains("[global::System.Runtime.CompilerServices.ModuleInitializer]", generated); + Assert.Contains( + "DefaultMemberAccessStrategy.RegisterSourceGeneratedAccessor(typeof(global::Person), new Person_GeneratedMemberAccessor_Inferred0(), new string[] { \"FirstName\" });", + generated); + Assert.DoesNotContain("typed.Hidden", generated); + Assert.Contains("typed.Method()", generated); + Assert.DoesNotContain("new string[] { \"PlainTask\" }", generated); + Assert.DoesNotContain("new string[] { \"ValueTask\" }", generated); + } + + [Fact] + public void ShouldNotInferModelTypeFromTemplateContextUsingDefaultOptions() + { + var source = """ + using Fluid; + + public class Person + { + public string FirstName { get; set; } = ""; + } + + public static class ContextFactory + { + public static TemplateContext Create(Person person) + => new TemplateContext(person); + } + """; + + var generated = RunGenerator(source); + + Assert.DoesNotContain("Person_GeneratedMemberAccessor", generated); + } + + [Fact] + public void ShouldNotInferModelTypeFromExplicitTemplateOptionsDefault() + { + var source = """ + using Fluid; + + public class Person + { + public string FirstName { get; set; } = ""; + } + + public static class ContextFactory + { + public static TemplateContext Create(Person person) + => new TemplateContext(person, TemplateOptions.Default); + } + """; + + var generated = RunGenerator(source); + + Assert.DoesNotContain("Person_GeneratedMemberAccessor", generated); + } + private static string RunGenerator(string source) { var syntaxTree = CSharpSyntaxTree.ParseText(source); @@ -122,7 +203,7 @@ private static string RunGenerator(string source) var driver = CSharpGeneratorDriver.Create(generator).RunGenerators(compilation); var runResult = driver.GetRunResult(); - Assert.Equal(2, runResult.GeneratedTrees.Length); + Assert.InRange(runResult.GeneratedTrees.Length, 1, 2); Assert.Empty(runResult.Diagnostics.Where(static x => x.Severity == DiagnosticSeverity.Error)); var outputCompilation = compilation.AddSyntaxTrees(runResult.GeneratedTrees); diff --git a/Fluid/DefaultMemberAccessStrategy.cs b/Fluid/DefaultMemberAccessStrategy.cs index eaf615f9..69dbd7c5 100644 --- a/Fluid/DefaultMemberAccessStrategy.cs +++ b/Fluid/DefaultMemberAccessStrategy.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using Fluid.Accessors; using System.Reflection; using System.Runtime.CompilerServices; @@ -20,10 +21,26 @@ public ReflectionCache(object registrationToken) public volatile Dictionary Accessors = []; } + private sealed class AccessorCacheState + { + public AccessorCacheState(object registrations, object generatedRegistrations) + { + Registrations = registrations; + GeneratedRegistrations = generatedRegistrations; + } + + public object Registrations { get; } + public object GeneratedRegistrations { get; } + } + private static readonly bool _dynamicCodeSupported = IsDynamicCodeSupported(); private volatile Dictionary _registrations = []; + private volatile Dictionary _generatedRegistrations = []; + private volatile object _generatedRegistryToken = GeneratedMemberAccessorRegistry.CacheToken; + private volatile Type _lastGeneratedType; private volatile ReflectionCache _reflectionCache; + private volatile AccessorCacheState _accessorCacheState; // Only the exact type opts in. A derived strategy may override GetAccessor to resolve from its // own source, which these maps -- and therefore the token -- would not reflect; it would then serve @@ -33,10 +50,19 @@ public ReflectionCache(object registrationToken) public DefaultMemberAccessStrategy() { _accessorCachingSupported = GetType() == typeof(DefaultMemberAccessStrategy); - _reflectionCache = new ReflectionCache(_registrations); + _accessorCacheState = new AccessorCacheState(_registrations, _generatedRegistrations); + _reflectionCache = new ReflectionCache(_accessorCacheState); } - protected internal override object AccessorCacheToken => _accessorCachingSupported ? _registrations : null; + protected internal override object AccessorCacheToken + => _accessorCachingSupported ? GetAccessorCacheState(_registrations, _generatedRegistrations) : null; + + /// + /// Registers an accessor emitted by the Fluid source generator. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void RegisterSourceGeneratedAccessor(Type type, MemberAccessor accessor, params string[] memberNames) + => GeneratedMemberAccessorRegistry.Register(type, accessor, memberNames); public override MemberAccessor GetAccessor(Type type, string name, StringComparer stringComparer) { @@ -45,13 +71,25 @@ public override MemberAccessor GetAccessor(Type type, string name, StringCompare ArgumentNullException.ThrowIfNull(stringComparer); var registrations = _registrations; + var generatedRegistrations = _generatedRegistrations; if (TryGetRegisteredAccessor(registrations, type, name, out var accessor)) { return accessor; } - var reflectionCache = GetReflectionCache(registrations); + if (generatedRegistrations.TryGetValue(type, out var generatedAccessors)) + { + foreach (var generatedAccessor in generatedAccessors) + { + if (generatedAccessor.CanAccess(name, stringComparer)) + { + return generatedAccessor.Accessor; + } + } + } + + var reflectionCache = GetReflectionCache(GetAccessorCacheState(registrations, generatedRegistrations)); var key = new ReflectedAccessorKey(type, name, stringComparer); var reflectedAccessors = reflectionCache.Accessors; @@ -231,7 +269,60 @@ [new AccessorKey(type, name)] = accessor Interlocked.CompareExchange(ref _registrations, updated, registrations), registrations)) { - _reflectionCache = new ReflectionCache(updated); + _reflectionCache = new ReflectionCache(GetAccessorCacheState(updated, _generatedRegistrations)); + return; + } + } + } + + internal override void RegisterGeneratedAccessor(Type type) + { + while (true) + { + var registryToken = GeneratedMemberAccessorRegistry.CacheToken; + var generatedRegistrations = _generatedRegistrations; + + if (ReferenceEquals(_generatedRegistryToken, registryToken)) + { + if (ReferenceEquals(_lastGeneratedType, type)) + { + return; + } + + if (generatedRegistrations.ContainsKey(type)) + { + _lastGeneratedType = type; + return; + } + } + + var accessors = GeneratedMemberAccessorRegistry.GetAccessors(type, out registryToken); + if (accessors is null) + { + _generatedRegistryToken = registryToken; + return; + } + + if (generatedRegistrations.TryGetValue(type, out var registeredAccessors) && + ReferenceEquals(registeredAccessors, accessors)) + { + _generatedRegistryToken = registryToken; + _lastGeneratedType = type; + return; + } + + var updated = new Dictionary(generatedRegistrations) + { + [type] = accessors + }; + + if (ReferenceEquals( + Interlocked.CompareExchange(ref _generatedRegistrations, updated, generatedRegistrations), + generatedRegistrations)) + { + _generatedRegistryToken = registryToken; + _lastGeneratedType = type; + _reflectionCache = new ReflectionCache(GetAccessorCacheState(_registrations, updated)); return; } } @@ -257,6 +348,28 @@ private ReflectionCache GetReflectionCache(object registrationToken) } } + private AccessorCacheState GetAccessorCacheState(object registrations, object generatedRegistrations) + { + while (true) + { + var state = _accessorCacheState; + + if (ReferenceEquals(state.Registrations, registrations) && + ReferenceEquals(state.GeneratedRegistrations, generatedRegistrations)) + { + return state; + } + + var updated = new AccessorCacheState(registrations, generatedRegistrations); + if (ReferenceEquals( + Interlocked.CompareExchange(ref _accessorCacheState, updated, state), + state)) + { + return updated; + } + } + } + private static void AddReflectedAccessor( ReflectionCache reflectionCache, ReflectedAccessorKey key, diff --git a/Fluid/GeneratedMemberAccessorRegistry.cs b/Fluid/GeneratedMemberAccessorRegistry.cs new file mode 100644 index 00000000..11068da3 --- /dev/null +++ b/Fluid/GeneratedMemberAccessorRegistry.cs @@ -0,0 +1,77 @@ +namespace Fluid +{ + internal sealed class GeneratedMemberAccessorRegistration + { + public GeneratedMemberAccessorRegistration(MemberAccessor accessor, string[] memberNames) + { + Accessor = accessor; + MemberNames = memberNames; + } + + public MemberAccessor Accessor { get; } + public string[] MemberNames { get; } + + public bool CanAccess(string name, StringComparer comparer) + { + foreach (var memberName in MemberNames) + { + if (comparer.Equals(name, memberName)) + { + return true; + } + } + + return false; + } + } + + internal static class GeneratedMemberAccessorRegistry + { + private sealed class RegistryState + { + public RegistryState(Dictionary accessors) + { + Accessors = accessors; + } + + public Dictionary Accessors { get; } + } + + private static volatile RegistryState _state = new([]); + + public static object CacheToken => _state; + + public static GeneratedMemberAccessorRegistration[] GetAccessors(Type runtimeType, out object cacheToken) + { + var state = _state; + cacheToken = state; + state.Accessors.TryGetValue(runtimeType, out var accessors); + return accessors; + } + + public static void Register(Type type, MemberAccessor accessor, string[] memberNames) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(accessor); + ArgumentNullException.ThrowIfNull(memberNames); + + while (true) + { + var state = _state; + var accessors = new Dictionary(state.Accessors); + var registration = new GeneratedMemberAccessorRegistration(accessor, memberNames.ToArray()); + + accessors[type] = state.Accessors.TryGetValue(type, out var existing) + ? [.. existing, registration] + : [registration]; + + var updated = new RegistryState(accessors); + + if (ReferenceEquals(Interlocked.CompareExchange(ref _state, updated, state), state)) + { + return; + } + } + } + } +} diff --git a/Fluid/MemberAccessStrategy.cs b/Fluid/MemberAccessStrategy.cs index 75076ba2..9132ed66 100644 --- a/Fluid/MemberAccessStrategy.cs +++ b/Fluid/MemberAccessStrategy.cs @@ -6,6 +6,10 @@ public abstract class MemberAccessStrategy public abstract void Register(Type type, string name, MemberAccessor accessor); + internal virtual void RegisterGeneratedAccessor(Type type) + { + } + /// /// Gets a token identifying the current set of accessors this strategy would return, or /// null to disable caching. Call sites may remember the diff --git a/Fluid/MemberAccessor.cs b/Fluid/MemberAccessor.cs index 02247758..19ac828a 100644 --- a/Fluid/MemberAccessor.cs +++ b/Fluid/MemberAccessor.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Fluid.Values; namespace Fluid @@ -21,6 +22,93 @@ public abstract class MemberAccessor /// The resolved Fluid value, or null when the accessor did not handle the name. public abstract ValueTask GetAsync(object obj, string name, TemplateContext context); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(bool value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? value ? BooleanValue.True : BooleanValue.False + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(byte value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(ushort value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(uint value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(sbyte value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(short value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(int value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(ulong value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(long value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(double value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create((decimal)value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(float value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create((decimal)value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(decimal value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? NumberValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(DateTime value, TemplateContext context) + => new(context.Options.ValueConverters.Count == 0 + ? new DateTimeValue(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static ValueTask CreateValueTask(string value, TemplateContext context) + => new(value is null + ? null + : context.Options.ValueConverters.Count == 0 + ? StringValue.Create(value) + : FluidValue.Create(value, context.Options)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] protected static ValueTask CreateValueTask(T value, TemplateContext context) { return new(value is null ? null : FluidValue.Create(value, context.Options)); diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index 914c0d1c..b4fcfa17 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -29,6 +29,11 @@ public TemplateContext(object model, TemplateOptions options) : this(options) { ArgumentNullException.ThrowIfNull(model); + if (!ReferenceEquals(options, TemplateOptions.Default)) + { + options.MemberAccessStrategy.RegisterGeneratedAccessor(model.GetType()); + } + if (model is FluidValue fluidValue) { Model = fluidValue; diff --git a/README.md b/README.md index bd33485b..7c5c3680 100644 --- a/README.md +++ b/README.md @@ -173,18 +173,43 @@ Fluid works when targeting NativeAOT and trimmed deployments. 1. Reuse `TemplateOptions` instances (for example, at app startup). 2. If you use runtime `MemberAccessStrategy.Register` calls, execute them during application startup before rendering templates. -3. Prefer `[FluidRegister]` on a custom `TemplateOptions` subclass for model types known at compile time. +3. Pass a statically typed model and custom options to `TemplateContext`, or use `[FluidRegister]` for types that are not visible at a context construction site. 4. Validate your app with AOT/trim publish settings: ```shell dotnet publish -c Release -r -p:PublishAot=true ``` +### Compatibility boundaries + +NativeAOT compatibility and trimming compatibility are related but separate. Fluid's reflection fallback does not emit code and can run when dynamic code is unavailable. A trimmed application must also preserve every member that the fallback discovers at runtime. + +| Usage | NativeAOT with trimming | +| --- | --- | +| `new TemplateContext(concreteModel, customOptions)` with the source generator enabled and matching compile-time and runtime model types | Compatible. Eligible public fields and properties are accessed directly by generated code. | +| A model registered with `[FluidRegister]` | Compatible. Use this for boxed models, nested model types, models created in another assembly, or types not visible at a `TemplateContext` construction site. | +| An explicit `MemberAccessStrategy.Register` mapping or custom `MemberAccessor` that accesses members directly | Compatible. The application supplies the access logic instead of relying on member discovery. | +| The one-argument `new TemplateContext(model)` constructor or `TemplateOptions.Default` | No accessor is inferred. Rendering uses an explicit registration if one exists; otherwise it falls back to reflection. | +| A model passed as `object`, an interface or base type whose runtime type differs, or an unregistered nested model | No accessor is inferred for the runtime type. Use `[FluidRegister]` for the concrete runtime type or register an accessor explicitly. | +| Reflection fallback for an unregistered type | NativeAOT-compatible only when the required public member metadata is preserved from trimming. Prefer source generation or explicit registration rather than relying on linker configuration. | +| A reflection-discovered `Task` member | Avoid in trimmed NativeAOT applications because the reflection fallback uses runtime dynamic binding to read the result. A generated or custom accessor handles `Task` without dynamic binding. | + +Source generation covers public readable properties and public fields that can be referenced from generated code. Members that cannot be generated continue through the normal registration and reflection fallback paths. If any required member uses a fallback path, validate the published application rather than assuming that source generation preserved it. + ### Source generation (optional) -When the `Fluid.SourceGenerator` analyzer is enabled, Fluid can generate strongly-typed member accessors for types declared with `FluidRegisterAttribute`. +When the `Fluid.SourceGenerator` analyzer is enabled, Fluid can generate strongly-typed member accessors for model types discovered at compile time. + +The model type is inferred automatically when its compile-time and runtime types match and it is passed with custom options: + +```csharp +var options = new TemplateOptions(); +var context = new TemplateContext(person, options); +``` + +The generated accessor is activated on the `DefaultMemberAccessStrategy` of the options instance passed to that constructor. Explicit registrations on `options.MemberAccessStrategy` still take precedence. The one-argument `TemplateContext(model)` constructor does not infer or activate a model accessor because it uses the shared `TemplateOptions.Default` instance. -The recommended pattern is to declare a custom `TemplateOptions` subclass and add one `FluidRegisterAttribute` per model type: +Use `FluidRegisterAttribute` when a model is passed as `object`, is created outside the compilation using the source generator, or when nested model types also need generated accessors. The recommended explicit pattern is to declare a custom `TemplateOptions` subclass and add one attribute per model type: ```csharp using Fluid;