Skip to content

Commit ac1f5a1

Browse files
authored
feat: interface-based observation plugin system with ReactiveUI-aligned affinities (#14)
1 parent f6d518b commit ac1f5a1

100 files changed

Lines changed: 5702 additions & 919 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
using Microsoft.CodeAnalysis.CSharp;
77

88
using ReactiveUI.Binding.SourceGenerators.Generators;
9-
using ReactiveUI.Binding.SourceGenerators.Generators.Observation;
109
using ReactiveUI.Binding.SourceGenerators.Helpers;
1110
using ReactiveUI.Binding.SourceGenerators.Invocations;
11+
using ReactiveUI.Binding.SourceGenerators.Models;
12+
using ReactiveUI.Binding.SourceGenerators.Plugins;
1213

1314
namespace ReactiveUI.Binding.SourceGenerators;
1415

@@ -60,18 +61,31 @@ internal static partial class __ReactiveUIGeneratedBindings
6061
.Where(static x => x is not null)
6162
.Select(static (x, _) => x!);
6263

63-
// Per-kind fallback generators FILTER from allClasses (no independent RegisterSourceOutput)
64-
var reactiveObjTypes = ReactiveObjectBindingGenerator.Filter(allClasses);
65-
var inpcTypes = INPCBindingGenerator.Filter(allClasses);
66-
var wpfTypes = WpfBindingGenerator.Filter(allClasses);
67-
var winuiTypes = WinUIBindingGenerator.Filter(allClasses);
68-
var kvoTypes = KVOBindingGenerator.Filter(allClasses);
69-
var winformsTypes = WinFormsBindingGenerator.Filter(allClasses);
70-
var androidTypes = AndroidBindingGenerator.Filter(allClasses);
64+
// Single plugin-based step replaces 7 separate filter calls.
65+
// Each type is matched against the plugin registry; the highest-affinity
66+
// matching plugin determines the observation kind and capabilities.
67+
var allObservableTypes = allClasses
68+
.Select(static (classInfo, _) =>
69+
{
70+
var plugin = ObservationPluginRegistry.GetBestPlugin(classInfo);
71+
if (plugin is null)
72+
{
73+
return null;
74+
}
75+
76+
return new ObservableTypeInfo(
77+
classInfo.FullyQualifiedName,
78+
classInfo.MetadataName,
79+
plugin.ObservationKind,
80+
plugin.Affinity,
81+
plugin.SupportsBeforeChanged,
82+
classInfo.Properties);
83+
})
84+
.Where(static x => x is not null)
85+
.Select(static (x, _) => x!);
7186

72-
// Consolidate all per-kind results → single RegisterSourceOutput
73-
var consolidated = RegistrationGenerator.Consolidate(
74-
reactiveObjTypes, inpcTypes, wpfTypes, winuiTypes, kvoTypes, winformsTypes, androidTypes);
87+
// Consolidate all observable types → single RegisterSourceOutput
88+
var consolidated = allObservableTypes.Collect();
7589

7690
context.RegisterSourceOutput(
7791
consolidated,

src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindCommandCodeGenerator.cs

Lines changed: 59 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
using System.Collections.Immutable;
66
using System.Text;
77

8-
using ReactiveUI.Binding.SourceGenerators.Generators.CommandBinding;
98
using ReactiveUI.Binding.SourceGenerators.Models;
9+
using ReactiveUI.Binding.SourceGenerators.Plugins;
1010

1111
namespace ReactiveUI.Binding.SourceGenerators.CodeGeneration;
1212

@@ -332,27 +332,22 @@ internal static void GenerateBindCommandMethod(
332332
ObservationCodeGenerator.EmitInlineObservation(
333333
sb, "viewModel", inv.CommandPropertyPath, inv.CommandTypeFullName, vmClassInfo, "commandObs");
334334

335-
// Try plugins in affinity order (highest first)
336-
if (CommandPropertyBindingPlugin.CanHandle(inv))
337-
{
338-
CommandPropertyBindingPlugin.EmitBinding(sb, inv, controlAccess);
339-
}
340-
else if (EventEnabledBindingPlugin.CanHandle(inv))
341-
{
342-
// Emit custom binder check before event+Enabled binding
343-
EmitCustomBinderFallback(sb, inv, controlAccess, hasEvent: true);
344-
EventEnabledBindingPlugin.EmitBinding(sb, inv, controlAccess);
345-
}
346-
else if (DefaultEventBindingPlugin.CanHandle(inv))
335+
// Try plugins in affinity order (highest first) via registry
336+
var plugin = CommandBindingPluginRegistry.GetBestPlugin(inv);
337+
var generatedAffinity = plugin is not null ? plugin.Affinity : -1;
338+
var hasEvent = inv.ResolvedEventName is not null;
339+
340+
// Emit affinity check: let user-registered ICreatesCommandBinding plugins override
341+
// if they have higher affinity than the source-generated binding
342+
EmitCommandAffinityCheck(sb, inv, controlAccess, generatedAffinity, hasEvent);
343+
344+
if (plugin is not null)
347345
{
348-
// Emit custom binder check before basic event binding
349-
EmitCustomBinderFallback(sb, inv, controlAccess, hasEvent: true);
350-
DefaultEventBindingPlugin.EmitBinding(sb, inv, controlAccess);
346+
plugin.EmitBinding(sb, inv, controlAccess);
351347
}
352348
else
353349
{
354-
// No plugin matched — emit runtime fallback for custom binders, then throw
355-
EmitCustomBinderFallback(sb, inv, controlAccess, hasEvent: false);
350+
// No plugin matched — throw after the affinity check fallback
356351
sb.AppendLine("""
357352
throw new global::System.InvalidOperationException(
358353
"No bindable event found on the control. Specify the 'toEvent' parameter.");
@@ -364,57 +359,73 @@ internal static void GenerateBindCommandMethod(
364359
}
365360

366361
/// <summary>
367-
/// Emits the custom binder check that tries registered <c>ICreatesCommandBinding</c> binders
368-
/// before falling through to the generated event subscription code.
362+
/// Emits the command binding affinity check that allows user-registered
363+
/// <c>ICreatesCommandBinding</c> implementations to override the generated binding
364+
/// when they have higher affinity. If no user plugin has higher affinity, falls through
365+
/// to the generated event subscription code.
369366
/// </summary>
370367
/// <param name="sb">The string builder.</param>
371368
/// <param name="inv">The BindCommand invocation info.</param>
372369
/// <param name="controlAccess">The control access chain (e.g., "view.MyButton").</param>
370+
/// <param name="generatedAffinity">The affinity of the source-generated plugin, or -1 if none.</param>
373371
/// <param name="hasEvent">Whether a resolved event was found at compile time.</param>
374-
internal static void EmitCustomBinderFallback(
372+
internal static void EmitCommandAffinityCheck(
375373
StringBuilder sb,
376374
BindCommandInvocationInfo inv,
377375
string controlAccess,
376+
int generatedAffinity,
378377
bool hasEvent)
379378
{
380379
// Build the parameter observable expression for the custom binder
381-
string paramObsExpr;
380+
var paramObsExpr = BuildParameterObservableExpression(inv);
381+
382+
sb.AppendLine($$"""
383+
384+
if (global::ReactiveUI.Binding.Fallback.CommandBindingAffinityChecker
385+
.HasHigherAffinityPlugin<{{inv.ControlTypeFullName}}>({{generatedAffinity}}, {{(hasEvent ? "true" : "false")}}))
386+
{
387+
var __customBinder = global::ReactiveUI.Binding.CommandBinding.CommandBinderService
388+
.GetBinder<{{inv.ControlTypeFullName}}>({{(hasEvent ? "true" : "false")}});
389+
if (__customBinder != null)
390+
{
391+
var __serial = new global::ReactiveUI.Binding.Observables.SerialDisposable();
392+
var __binderCmdSub = global::ReactiveUI.Binding.Observables.RxBindingExtensions.Subscribe(commandObs, __cmd =>
393+
{
394+
__serial.Disposable = global::ReactiveUI.Binding.Observables.EmptyDisposable.Instance;
395+
global::System.IObservable<object> __paramObs = {{paramObsExpr}};
396+
__serial.Disposable = __customBinder.BindCommandToObject<{{inv.ControlTypeFullName}}>(
397+
__cmd, {{controlAccess}}, __paramObs)
398+
?? global::ReactiveUI.Binding.Observables.EmptyDisposable.Instance;
399+
});
400+
return new global::ReactiveUI.Binding.Observables.CompositeDisposable2(__binderCmdSub, __serial);
401+
}
402+
}
403+
404+
""");
405+
}
406+
407+
/// <summary>
408+
/// Builds the parameter observable expression string for custom binder fallback code.
409+
/// </summary>
410+
/// <param name="inv">The BindCommand invocation info.</param>
411+
/// <returns>The parameter observable expression to embed in generated code.</returns>
412+
internal static string BuildParameterObservableExpression(BindCommandInvocationInfo inv)
413+
{
382414
if (inv.HasObservableParameter)
383415
{
384416
// Cast the typed observable to IObservable<object> via Select
385-
paramObsExpr = "new global::ReactiveUI.Binding.Observables.SelectObservable<"
417+
return "new global::ReactiveUI.Binding.Observables.SelectObservable<"
386418
+ inv.ParameterTypeFullName + ", object>(withParameter, __p => __p)";
387419
}
388-
else if (inv is { HasExpressionParameter: true, ParameterPropertyPath: not null })
420+
421+
if (inv is { HasExpressionParameter: true, ParameterPropertyPath: not null })
389422
{
390423
// Read the parameter property at call time
391424
var paramAccess = CodeGeneratorHelpers.BuildPropertyAccessChain("viewModel", inv.ParameterPropertyPath.Value);
392-
paramObsExpr = "new global::ReactiveUI.Binding.Observables.ReturnObservable<object>(" + paramAccess + ")";
393-
}
394-
else
395-
{
396-
paramObsExpr = "global::ReactiveUI.Binding.Observables.EmptyObservable<object>.Instance";
425+
return "new global::ReactiveUI.Binding.Observables.ReturnObservable<object>(" + paramAccess + ")";
397426
}
398427

399-
sb.AppendLine($$"""
400-
401-
var __customBinder = global::ReactiveUI.Binding.CommandBinding.CommandBinderService
402-
.GetBinder<{{inv.ControlTypeFullName}}>({{(hasEvent ? "true" : "false")}});
403-
if (__customBinder != null)
404-
{
405-
var __serial = new global::ReactiveUI.Binding.Observables.SerialDisposable();
406-
var __binderCmdSub = global::ReactiveUI.Binding.Observables.RxBindingExtensions.Subscribe(commandObs, __cmd =>
407-
{
408-
__serial.Disposable = global::ReactiveUI.Binding.Observables.EmptyDisposable.Instance;
409-
global::System.IObservable<object> __paramObs = {{paramObsExpr}};
410-
__serial.Disposable = __customBinder.BindCommandToObject<{{inv.ControlTypeFullName}}>(
411-
__cmd, {{controlAccess}}, __paramObs)
412-
?? global::ReactiveUI.Binding.Observables.EmptyDisposable.Instance;
413-
});
414-
return new global::ReactiveUI.Binding.Observables.CompositeDisposable2(__binderCmdSub, __serial);
415-
}
416-
417-
""");
428+
return "global::ReactiveUI.Binding.Observables.EmptyObservable<object>.Instance";
418429
}
419430

420431
/// <summary>

0 commit comments

Comments
 (0)