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 1/2] 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; + } +} From 5a2da4af9f1a6e0d860b536d7af81715cea7c209 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:36:30 +0100 Subject: [PATCH 2/2] Update README.md --- README.md | 114 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8af59f0..7bb3273 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A C# source generator that automatically implements property change notification - Automatic `INotifyPropertyChanged` implementation - Class-level and property-level reactive declarations - Support for ReactiveUI's `ReactiveObject` pattern +- Property-level opt-out using `[IgnoreReactive]` +- Support for custom property implementations - Automatic `WhenAnyValue` observable generation for reactive properties - Support for modern C# field keyword and legacy backing fields - Full nullable reference type support @@ -19,14 +21,6 @@ A C# source generator that automatically implements property change notification - Flexible property access modifiers - Cached `PropertyChangedEventArgs` for performance optimization -### Performance Optimizations -- Static caching of PropertyChangedEventArgs instances -- Efficient property change detection using Equals -- Minimal memory allocation during updates -- Zero-overhead property access for unchanged values -- Optimized WhenAnyValue observable implementation with weak event handling -- Thread-safe event management - ## Installation ```bash @@ -65,6 +59,54 @@ public partial class Person } ``` +### Advanced Property Control + +#### Opting Out of Class-Level Reactivity + +When a class is marked with `[Reactive]`, you can selectively opt out specific properties using `[IgnoreReactive]`: + +```csharp +[Reactive] +public partial class Shape +{ + public partial string? Name { get; set; } // Will be reactive + + [IgnoreReactive] + public partial string? Tag { get; set; } // Won't be reactive +} +``` + +#### Custom Property Implementations + +You can provide custom implementations for properties marked with `[IgnoreReactive]`: + +```csharp +[Reactive] +public partial class Shape +{ + public partial string? Name { get; set; } // Generated reactive implementation + + [IgnoreReactive] + public partial string? Tag { get; set; } // Custom implementation below +} + +public partial class Shape +{ + private string? _tag; + + public partial string? Tag + { + get => _tag; + set => _tag = value; + } +} +``` + +This allows you to: +- Mix generated and custom property implementations +- Take full control of specific properties when needed +- Maintain the reactive pattern for most properties while customizing others + ### ReactiveUI Integration ```csharp @@ -74,7 +116,7 @@ public partial class ViewModel : ReactiveObject public partial string SearchText { get; set; } [Reactive] - public partial bool IsLoading { get; set; } + public partial string Status { get; set; } public ViewModel() { @@ -93,46 +135,20 @@ public partial class ViewModel : ReactiveObject - 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 +4. `[IgnoreReactive]` can be used to: + - Opt out of class-level reactivity + - Allow custom property implementations + +### Property Implementation Rules +1. Default Generation: + - Properties without implementations get reactive implementations + - Properties with existing implementations are respected +2. Mixed Implementations: + - Can mix generated and custom implementations in the same class + - Custom implementations take precedence over generation +3. Implementation Override: + - Mark property with `[IgnoreReactive]` to provide custom implementation + - Custom implementation can be in any partial class declaration ## Configuration