From 711d50b4d8cd4b0ffa8f5e5a48b7516c0c5847a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20S=CC=8Colte=CC=81s?= Date: Wed, 27 Nov 2024 21:33:29 +0100 Subject: [PATCH] Class level generation --- README.md | 180 +++++++++------------- ReactiveGenerator/ReactiveGenerator.cs | 99 ++++++++---- ReactiveGeneratorDemo/ViewModels/Shape.cs | 21 +++ 3 files changed, 161 insertions(+), 139 deletions(-) create mode 100644 ReactiveGeneratorDemo/ViewModels/Shape.cs diff --git a/README.md b/README.md index dc9c6f2..8af59f0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A C# source generator that automatically implements property change notification ### Core Features - Automatic `INotifyPropertyChanged` implementation +- Class-level and property-level reactive declarations - Support for ReactiveUI's `ReactiveObject` pattern - Automatic `WhenAnyValue` observable generation for reactive properties - Support for modern C# field keyword and legacy backing fields @@ -40,7 +41,18 @@ dotnet add package ReactiveGenerator ## Basic Usage -### Standard INPC Implementation +### Class-Level Reactive Declaration + +```csharp +[Reactive] +public partial class Person +{ + public partial string FirstName { get; set; } + public partial string LastName { get; set; } +} +``` + +### Property-Level Reactive Declaration ```csharp public partial class Person @@ -73,6 +85,55 @@ public partial class ViewModel : ReactiveObject } ``` +## Generation Rules + +### Attribute Rules +1. `[Reactive]` can be applied at: + - Class level: All partial properties in the class become reactive + - Property level: Individual properties become reactive +2. Properties marked with `[Reactive]` must be declared as `partial` +3. Classes containing reactive properties must be declared as `partial` + +### INPC Implementation Rules +1. If a class is not inheriting from `ReactiveObject`: + - INPC interface is automatically implemented + - PropertyChanged event is generated + - OnPropertyChanged methods are generated +2. INPC implementation is generated only once in the inheritance chain +3. Base class INPC implementation is respected and reused + +### Property Generation Rules +1. Access Modifiers: + - Property access level is preserved + - Get/set accessor modifiers are preserved + - Generated backing fields follow property accessibility + +2. Nullability: + - Nullable annotations are preserved + - Reference type nullability is correctly propagated + - Value type nullability is handled appropriately + +3. Inheritance: + - Virtual/override modifiers are preserved + - Base class properties are respected + - Multiple inheritance levels are handled correctly + +4. Field Generation: + - Modern mode (default): Uses C# field keyword + - Legacy mode: Uses explicit backing fields with underscore prefix + - Static PropertyChangedEventArgs instances are cached per property + +### Observable Generation Rules +1. For each reactive property: + - Type-safe WhenAny extension method is generated + - Weak event handling is implemented + - Thread-safe subscription management is provided + +2. Observable Lifecycle: + - Automatic cleanup of unused subscriptions + - Memory leak prevention through weak references + - Proper disposal handling + ## Configuration ### MSBuild Properties @@ -94,141 +155,38 @@ The generator produces several key types: 2. `WeakEventManager`: Provides thread-safe weak event handling 3. Extension methods for each reactive property (e.g., `WhenAnyPropertyName`) -### Generated Code Structure - -The generator produces two main types of code: - -1. Property Change Notifications: - - INPC implementation for classes not inheriting from ReactiveObject - - Efficient property setters with change detection - - Cached PropertyChangedEventArgs instances - -2. WhenAnyValue Observables: - - Type-safe property observation methods - - Weak event handling to prevent memory leaks - - Thread-safe subscription management - -### Property Implementation Modes - -#### Modern Mode (Default) -Uses C# field keyword: +### Common Issues and Solutions -```csharp -public partial string Property -{ - get => field; - set - { - if (!Equals(field, value)) - { - field = value; - OnPropertyChanged(_propertyChangedEventArgs); - } - } -} -``` - -#### Legacy Mode -Uses explicit backing fields: - -```csharp -private string _property; -public partial string Property -{ - get => _property; - set - { - if (!Equals(_property, value)) - { - _property = value; - OnPropertyChanged(_propertyChangedEventArgs); - } - } -} -``` - -## Advanced Features - -### Weak Event Management - -The generator includes a sophisticated weak event system that: -- Prevents memory leaks in long-lived observable subscriptions -- Automatically cleans up when observers are garbage collected -- Maintains thread safety for event handling - -### Inheritance Support - -The generator is fully aware of inheritance hierarchies: -- Correctly implements INPC only once in the inheritance chain -- Properly handles virtual and override properties -- Supports mixed INPC and ReactiveUI inheritance scenarios - -### Performance Optimizations - -1. Event Args Caching: -```csharp -private static readonly PropertyChangedEventArgs _propertyChangedEventArgs = - new PropertyChangedEventArgs(nameof(Property)); -``` - -2. Efficient Change Detection: -```csharp -if (!Equals(field, value)) -{ - field = value; - OnPropertyChanged(_propertyChangedEventArgs); -} -``` - -3. Weak Event References: -```csharp -internal sealed class WeakEventManager -{ - private readonly ConditionalWeakTable _registrations; - // Implementation details... -} -``` - -## Best Practices - -1. Always mark reactive classes and properties as `partial` -2. Use `IDisposable` for proper subscription cleanup -3. Consider thread safety when using observables -4. Implement `IEquatable` for complex property types -5. Use the generated WhenAny methods instead of magic strings - -## Common Issues and Solutions - -### Missing Partial Declarations +#### Missing Partial Declarations ```csharp // ❌ Wrong +[Reactive] public class Example { - [Reactive] public string Property { get; set; } } // ✅ Correct +[Reactive] public partial class Example { - [Reactive] public partial string Property { get; set; } } ``` -### ReactiveUI Base Class +#### ReactiveUI Base Class ```csharp // ❌ Wrong: Missing ReactiveObject base +[Reactive] public partial class Example { - [Reactive] public partial string Property { get; set; } } // ✅ Correct +[Reactive] public partial class Example : ReactiveObject { - [Reactive] public partial string Property { get; set; } } ``` diff --git a/ReactiveGenerator/ReactiveGenerator.cs b/ReactiveGenerator/ReactiveGenerator.cs index 0093303..fafb1ec 100644 --- a/ReactiveGenerator/ReactiveGenerator.cs +++ b/ReactiveGenerator/ReactiveGenerator.cs @@ -11,12 +11,19 @@ namespace ReactiveGenerator; [Generator] public class ReactiveGenerator : IIncrementalGenerator { + private record PropertyInfo( + IPropertySymbol Property, + bool HasReactiveAttribute, + bool HasIgnoreAttribute, + bool HasImplementation); + public void Initialize(IncrementalGeneratorInitializationContext context) { - // Register the attribute source with updated AttributeUsage + // Register both attributes context.RegisterPostInitializationOutput(ctx => { ctx.AddSource("ReactiveAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)); + ctx.AddSource("IgnoreReactiveAttribute.g.cs", SourceText.From(IgnoreAttributeSource, Encoding.UTF8)); }); // Get MSBuild property for enabling legacy mode @@ -27,7 +34,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) : "false", out var result) && result); - // Get both partial class declarations and partial properties + // Get partial class declarations var partialClasses = context.SyntaxProvider .CreateSyntaxProvider( predicate: (s, _) => s is ClassDeclarationSyntax c && @@ -36,13 +43,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) transform: (ctx, _) => GetClassInfo(ctx)) .Where(m => m is not null); + // Get partial properties (both with [Reactive] and from [Reactive] classes) var partialProperties = context.SyntaxProvider .CreateSyntaxProvider( - // Update predicate to check for [Reactive] attribute predicate: (s, _) => s is PropertyDeclarationSyntax p && - p.Modifiers.Any(m => m.ValueText == "partial") && - p.AttributeLists.Any(al => al.Attributes.Any(a => - a.Name.ToString() is "Reactive" or "ReactiveAttribute")), + p.Modifiers.Any(m => m.ValueText == "partial"), transform: (ctx, _) => GetPropertyInfo(ctx)) .Where(m => m is not null); @@ -57,7 +62,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) (spc, source) => Execute( source.Left.Left.Left, source.Left.Left.Right.Cast<(INamedTypeSymbol Type, Location Location)>().ToList(), - source.Left.Right.Cast<(IPropertySymbol Property, Location Location)>().ToList(), + source.Left.Right.Cast().ToList(), source.Right, spc)); } @@ -85,44 +90,59 @@ private static (INamedTypeSymbol Type, Location Location)? GetClassInfo(Generato return null; } - - private static (IPropertySymbol Property, Location Location)? GetPropertyInfo(GeneratorSyntaxContext context) + + private static PropertyInfo? GetPropertyInfo(GeneratorSyntaxContext context) { - // Check if this is a property declaration if (context.Node is not PropertyDeclarationSyntax propertyDeclaration) return null; - // Check if the property has the [Reactive] attribute + var symbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration) as IPropertySymbol; + if (symbol == null) + return null; + bool hasReactiveAttribute = false; + bool hasIgnoreAttribute = false; + foreach (var attributeList in propertyDeclaration.AttributeLists) { foreach (var attribute in attributeList.Attributes) { var name = attribute.Name.ToString(); if (name is "Reactive" or "ReactiveAttribute") - { hasReactiveAttribute = true; - break; - } + else if (name is "IgnoreReactive" or "IgnoreReactiveAttribute") + hasIgnoreAttribute = true; } + } - if (hasReactiveAttribute) break; + // Check if containing type has [Reactive] attribute + var containingType = symbol.ContainingType; + bool classHasReactiveAttribute = false; + foreach (var attribute in containingType.GetAttributes()) + { + if (attribute.AttributeClass?.Name is "ReactiveAttribute" or "Reactive") + { + classHasReactiveAttribute = true; + break; + } } - // If no [Reactive] attribute is found, skip this property - if (!hasReactiveAttribute) - return null; + // Check if property has an implementation + bool hasImplementation = propertyDeclaration.AccessorList?.Accessors.Any( + a => a.Body != null || a.ExpressionBody != null) ?? false; - // Get the property symbol - var symbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration) as IPropertySymbol; - if (symbol != null) + // Return property info if it either: + // 1. Has [Reactive] attribute directly + // 2. Is in a class with [Reactive] attribute and doesn't have [IgnoreReactive] + // 3. Has no implementation yet + if ((hasReactiveAttribute || (classHasReactiveAttribute && !hasIgnoreAttribute)) && !hasImplementation) { - return (symbol, propertyDeclaration.GetLocation()); + return new PropertyInfo(symbol, hasReactiveAttribute, hasIgnoreAttribute, hasImplementation); } return null; } - + private static bool InheritsFromReactiveObject(INamedTypeSymbol typeSymbol) { var current = typeSymbol; @@ -153,7 +173,7 @@ private static int GetTypeHierarchyDepth(INamedTypeSymbol type) private static void Execute( Compilation compilation, List<(INamedTypeSymbol Type, Location Location)> reactiveClasses, - List<(IPropertySymbol Property, Location Location)> properties, + List properties, bool useLegacyMode, SourceProductionContext context) { @@ -177,11 +197,16 @@ private static void Execute( foreach (var reactiveClass in reactiveClasses) allTypes.Add(reactiveClass.Type); - // Add types from properties with [Reactive] attribute + // Add types from properties that should be reactive and don't have implementation foreach (var property in properties) { - if (property.Property.ContainingType is INamedTypeSymbol type) + if ((property.HasReactiveAttribute || + (reactiveTypesSet.Contains(property.Property.ContainingType) && !property.HasIgnoreAttribute)) && + !property.HasImplementation && + property.Property.ContainingType is INamedTypeSymbol type) + { allTypes.Add(type); + } } // First pass: Process base types that need INPC @@ -206,7 +231,7 @@ private static void Execute( // Check if type needs INPC implementation var needsInpc = !HasINPCImplementation(compilation, type, processedTypes) && (reactiveTypesSet.Contains(type) || // Has [Reactive] class attribute - propertyGroups.ContainsKey(type)); // Has [Reactive] properties + propertyGroups.ContainsKey(type)); // Has properties that should be reactive if (needsInpc) { @@ -226,9 +251,19 @@ private static void Execute( var typeSymbol = group.Key as INamedTypeSymbol; if (typeSymbol == null) continue; + // Filter properties that should be reactive + var reactiveProperties = group.Value + .Where(p => p.HasReactiveAttribute || + (reactiveTypesSet.Contains(typeSymbol) && !p.HasIgnoreAttribute)) + .Select(p => p.Property) + .ToList(); + + if (!reactiveProperties.Any()) + continue; + var source = GenerateClassSource( typeSymbol, - group.Value.Select(p => p.Property).ToList(), + reactiveProperties, implementInpc: false, // INPC already implemented in first pass if needed useLegacyMode); @@ -586,4 +621,12 @@ sealed class ReactiveAttribute : Attribute { public ReactiveAttribute() { } }"; + + private const string IgnoreAttributeSource = @"using System; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +sealed class IgnoreReactiveAttribute : Attribute +{ + public IgnoreReactiveAttribute() { } +}"; } diff --git a/ReactiveGeneratorDemo/ViewModels/Shape.cs b/ReactiveGeneratorDemo/ViewModels/Shape.cs new file mode 100644 index 0000000..95f0c46 --- /dev/null +++ b/ReactiveGeneratorDemo/ViewModels/Shape.cs @@ -0,0 +1,21 @@ +namespace ReactiveGeneratorDemo.ViewModels; + +[Reactive] +public partial class Shape +{ + public partial string? Name { get; set; } + + [IgnoreReactive] + public partial string? Tag { get; set; } +} + +public partial class Shape +{ + private string? _tag; + + public partial string? Tag + { + get => _tag; + set => _tag = value; + } +}