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
21 changes: 20 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ src/
│ │ ├── WinFormsBindingGenerator.cs # WinForms Component (affinity 23)
│ │ ├── AndroidBindingGenerator.cs # Android View (affinity 19)
│ │ ├── RegistrationGenerator.cs # Consolidates all → [ModuleInitializer]
│ │ ├── ObservationHelperGenerator.cs # Declares the KVO/WinUI helper classes, once per compilation
│ │ └── ViewLocatorDispatchGenerator.cs # IViewFor<T> → AOT view dispatch (Pipeline C)
│ ├── Invocations/ # Per-invocation generators (Pipeline B)
│ │ ├── WhenChangedInvocationGenerator.cs # After-change observation
Expand Down Expand Up @@ -418,6 +419,20 @@ All pipeline models are `sealed record` types with value equality. NEVER include
- `#pragma warning disable` at top of generated files
- All generated types use `[Microsoft.CodeAnalysis.Embedded]` attribute

### Where the Observation Helper Classes Are Declared

Some plugins (`KVOObservationPlugin`, `WinUIObservationPlugin`) emit observation code that instantiates helper
classes by bare name — `__KVOObservable<T>`, `__KVOObserver`, `__WinUIDPObservable<T>`. Every dispatch file is
another part of the same `__ReactiveUIGeneratedBindings` class, so one part declaring them is enough for all of
them, and two parts declaring them is a duplicate-member error.

`ObservationHelperGenerator` therefore owns the declarations outright, in `ObservationHelpers.g.cs`. Emitters
only ever reference the helpers; none of them declare any. Which helpers to declare is decided from the
**detected types**, not from the call sites — a reference can only be emitted for a type
`CodeGeneratorHelpers.FindClassInfo` matched, so the declarations are a superset of the references whichever
binding API reaches for them. Deciding it from the call sites is what left `BindOneWay`, `BindTwoWay`, `Bind`,
`OneWayBind`, `WhenAny` and `WhenAnyObservable` emitting references to types nobody declared.

### Two-Layer Language Version Constraint

There are **two distinct C# language contexts** in this project:
Expand Down Expand Up @@ -576,6 +591,10 @@ build keeps working right up until Wine starts. Each copy chains to the reposito
- **Generator + Analyzer targets:** netstandard2.0 (Roslyn requirement)
- **Runtime library targets:** net8.0;net9.0;net10.0;net462;net472;net481
- **No shallow clones:** Repository requires full clone for Nerdbank.GitVersioning
- **PackBuildOutputs target:** Generator .csproj packages both generator and analyzer DLLs into `analyzers/dotnet/cs`
- **Where the analyzers ship:** `ReactiveUI.Binding` and `ReactiveUI.Binding.Reactive` each pack the generator
and analyzer DLLs into `analyzers/dotnet/cs`, so referencing a runtime package is all a consumer needs.
`ReactiveUI.Binding.SourceGenerators` is a compatibility package that ships only the MSBuild props: a second
copy of the same assemblies under a different package root loads as a second generator and emits every
dispatch file twice, which fails the consumer's build

**Philosophy:** Generate zero-reflection, AOT-compatible property observation and binding code at compile-time. Support all ReactiveUI platform notification mechanisms. Fall back to runtime expression analysis only when compile-time analysis is not possible.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ Higher affinity values take priority when a type implements multiple mechanisms.
| Package | Description | NuGet |
|---------------------------------------|------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ReactiveUI.Binding` | Runtime library with lightweight observables. No System.Reactive dependency. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.svg)](https://www.nuget.org/packages/ReactiveUI.Binding) |
| `ReactiveUI.Binding.SourceGenerators` | Source generator (auto-referenced by the Binding package). | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.SourceGenerators.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.SourceGenerators) |
| `ReactiveUI.Binding.SourceGenerators` | Compatibility package; the generator ships inside the runtime packages. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.SourceGenerators.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.SourceGenerators) |
| `ReactiveUI.Binding.Reactive` | System.Reactive adapter for IScheduler overloads. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.Reactive.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.Reactive) |
| `ReactiveUI.Binding.Wpf` | WPF DependencyProperty support. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.Wpf.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.Wpf) |
| `ReactiveUI.Binding.WinForms` | WinForms Component support. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.WinForms.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.WinForms) |
Expand Down
17 changes: 17 additions & 0 deletions src/ReactiveUI.Binding.Reactive/ReactiveUI.Binding.Reactive.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@
<PackageReference Include="System.Reactive"/>
</ItemGroup>

<!-- The runtime library is useless on its own: every API it exposes is a stub that throws unless the
generator has replaced the call site, so the generator and its analyzer ship in this package rather
than being something the consumer has to remember to add. Referenced only to order the build - the
generator is not run over this project's own source. -->
<ItemGroup>
<ProjectReference Include="..\ReactiveUI.Binding.SourceGenerators\ReactiveUI.Binding.SourceGenerators.csproj" PrivateAssets="all" ReferenceOutputAssembly="false"/>
</ItemGroup>

<!-- Packed as plain files rather than through TargetsForTfmSpecificContentInPackage: that hook runs once
per target framework, and the analyzer folder is framework-agnostic, so it would offer the same path
six times over. -->
<ItemGroup>
<None Include="..\ReactiveUI.Binding.SourceGenerators\bin\$(Configuration)\netstandard2.0\ReactiveUI.Binding.SourceGenerators.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false"/>
<None Include="..\ReactiveUI.Binding.Analyzer\bin\$(Configuration)\netstandard2.0\ReactiveUI.Binding.Analyzer.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false"/>
<None Include="..\ReactiveUI.Binding.SourceGenerators\build\ReactiveUI.Binding.SourceGenerators.props" Pack="true" PackagePath="build\$(MSBuildProjectName).props;buildTransitive\$(MSBuildProjectName).props" Visible="false"/>
</ItemGroup>

<!-- DateOnly and TimeOnly only exist on .NET 6+. Their converters are guarded with
#if NET6_0_OR_GREATER, so exclude the sources outright on the older targets rather than
compiling files that declare nothing there. -->
Expand Down
27 changes: 27 additions & 0 deletions src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
consolidated.Combine(languageFeatures),
static (ctx, data) => RegistrationGenerator.Generate(ctx, data.Left, data.Right));

RegisterObservationHelperOutput(in context, allObservableTypes, languageFeatures);

// Pipeline C: View locator dispatch (IViewFor<T> scanning)
ViewLocatorDispatchGenerator.Register(context, languageFeatures);

Expand Down Expand Up @@ -102,6 +104,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
BindToInvocationGenerator.Register(context, bindTo, allClasses, languageFeatures);
}

/// <summary>
/// Declares the observation helper classes that generated observation code instantiates by name, once
/// for the whole compilation.
/// </summary>
/// <param name="context">The generator initialization context.</param>
/// <param name="observableTypes">Every detected type that has an observation plugin.</param>
/// <param name="languageFeatures">The consumer's language-feature snapshot, which names the namespace.</param>
/// <remarks>
/// Keyed to the detected types rather than to the call sites, which keeps the declarations a superset of
/// the references: observation code can only name a helper for a detected type, whichever binding API
/// reaches for it. Collapsing the per-type kinds to a distinct set first means adding another type of an
/// already-seen kind leaves this output cached.
/// </remarks>
private static void RegisterObservationHelperOutput(
in IncrementalGeneratorInitializationContext context,
IncrementalValuesProvider<ObservableTypeInfo> observableTypes,
IncrementalValueProvider<LanguageFeatures> languageFeatures) =>
context.RegisterSourceOutput(
observableTypes
.Select(static (typeInfo, _) => typeInfo.ObservationKind)
.Collect()
.Select(static (kinds, _) => ObservationHelperGenerator.SelectHelperKinds(kinds))
.Combine(languageFeatures),
static (ctx, data) => ObservationHelperGenerator.Generate(ctx, data.Left, data.Right));

/// <summary>Runs one syntax scan and keeps the call sites it could extract.</summary>
/// <typeparam name="T">The extracted call-site model.</typeparam>
/// <param name="context">The generator initialization context.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,14 @@ internal static bool IsINPChanging(ClassBindingInfo? classInfo) =>
CodeGeneratorHelpers.AppendExtensionClassHeader(sb, features);
_ = sb.AppendLine();

// Track which plugins with helper classes are used, so we emit them once
var usedPluginKinds = new HashSet<string>();

// Group invocations by their method signature
var groups = GroupByTypeSignature(invocations);

for (var g = 0; g < groups.Count; g++)
{
GenerateGroup(sb, groups[g], allClasses, supportsCallerArgExpr, features.StubHasExpressionParameters, methodPrefix, usedPluginKinds);
GenerateGroup(sb, groups[g], allClasses, supportsCallerArgExpr, features.StubHasExpressionParameters, methodPrefix);
}

EmitUsedHelperClasses(sb, usedPluginKinds);

CodeGeneratorHelpers.AppendExtensionClassFooter(sb);
_ = sb.AppendLine();

Expand Down Expand Up @@ -905,25 +900,20 @@ private static string MethodSuffix(InvocationInfo inv) =>
0,
string.Join("|", inv.ExpressionTexts));

/// <summary>
/// Generates the concrete overload and per-invocation observation methods for a single type group,
/// tracking which plugins require helper-class emission.
/// </summary>
/// <summary>Generates the concrete overload and per-invocation observation methods for a single type group.</summary>
/// <param name="sb">The string builder to append to.</param>
/// <param name="group">The type group to generate code for.</param>
/// <param name="allClasses">All detected class binding info for type mechanism lookup.</param>
/// <param name="supportsCallerArgExpr">Whether the target language version supports CallerArgumentExpression.</param>
/// <param name="stubHasExpressionParameters">Whether the runtime stub declares the expression parameters this overload has to match.</param>
/// <param name="methodPrefix">The method name prefix.</param>
/// <param name="usedPluginKinds">Accumulates the observation kinds of plugins that require helper classes.</param>
private static void GenerateGroup(
StringBuilder sb,
TypeGroup group,
ImmutableArray<ClassBindingInfo> allClasses,
bool supportsCallerArgExpr,
bool stubHasExpressionParameters,
string methodPrefix,
HashSet<string> usedPluginKinds)
string methodPrefix)
{
// Resolve the plugin affinity for the source type to emit the runtime override check
var groupClassInfo = CodeGeneratorHelpers.FindClassInfo(allClasses, group.SourceTypeFullName);
Expand Down Expand Up @@ -951,34 +941,10 @@ private static void GenerateGroup(

var classInfo = CodeGeneratorHelpers.FindClassInfo(allClasses, inv.SourceTypeFullName);

// Track plugin usage for helper class emission
if (classInfo is not null)
{
var plugin = ObservationPluginRegistry.GetBestPlugin(classInfo);
if (plugin?.RequiresHelperClasses == true)
{
_ = usedPluginKinds.Add(plugin.ObservationKind);
}
}

GenerateObservationMethod(sb, inv, classInfo, suffix, inv.IsBeforeChange, methodPrefix);
}
}

/// <summary>Emits helper classes for all used plugins that require them, sorted for deterministic output order.</summary>
/// <param name="sb">The string builder to append to.</param>
/// <param name="usedPluginKinds">The observation kinds of plugins requiring helper classes.</param>
private static void EmitUsedHelperClasses(StringBuilder sb, HashSet<string> usedPluginKinds)
{
var sortedKinds = new List<string>(usedPluginKinds);
sortedKinds.Sort(StringComparer.Ordinal);
for (var k = 0; k < sortedKinds.Count; k++)
{
var plugin = ObservationPluginRegistry.GetPluginByKind(sortedKinds[k]);
plugin?.EmitHelperClasses(sb);
}
}

/// <summary>Emits the trailing named-tuple projection lambda for a selector-less <c>CombineLatest</c> call.</summary>
/// <param name="sb">The string builder to append to.</param>
/// <param name="propertyCount">The number of property path observables being combined.</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using ReactiveUI.Binding.SourceGenerators.CodeGeneration;
using ReactiveUI.Binding.SourceGenerators.Models;
using ReactiveUI.Binding.SourceGenerators.Plugins;

namespace ReactiveUI.Binding.SourceGenerators.Generators;

/// <summary>
/// Declares the platform observation helper classes - the fused observables and observer shims that
/// generated observation code instantiates by name, such as the Apple KVO and WinUI dependency-property
/// observables.
/// </summary>
/// <remarks>
/// <para>
/// The helpers are declared once for the whole compilation, in a file of their own, because every dispatch
/// file is another part of the same <c>__ReactiveUIGeneratedBindings</c> class: one part declares them and
/// all the others reach them. Letting each file declare the helpers it happens to use would collide as soon
/// as two files used the same one, and letting one dispatch file own them - which is what used to happen -
/// left every other file referencing types that were never declared.
/// </para>
/// <para>
/// Which helpers to declare is decided from the detected types rather than from the call sites, so the
/// declarations are a superset of the references: observation code can only name a helper for a type this
/// pipeline detected, whichever API the call site used. A future binding API therefore cannot reintroduce
/// the undeclared-helper failure by forgetting to register itself here.
/// </para>
/// </remarks>
internal static class ObservationHelperGenerator
{
/// <summary>The generated file the helper classes are declared in.</summary>
private const string HintName = "ObservationHelpers.g.cs";

/// <summary>Buffer capacity to reserve per helper-requiring observation kind.</summary>
private const int PerKindBufferCapacity = 4_096;

/// <summary>
/// Reduces the per-type observation kinds to the distinct, ordered set of kinds that need helper
/// declarations, so adding another type of an already-seen kind leaves the generated file untouched.
/// </summary>
/// <param name="observationKinds">The observation kind of every detected type, with repeats.</param>
/// <returns>The kinds requiring helper declarations, ordered for deterministic output.</returns>
internal static EquatableArray<string> SelectHelperKinds(ImmutableArray<string> observationKinds)
{
if (observationKinds.IsDefaultOrEmpty)
{
return default;
}

var kinds = new SortedSet<string>(StringComparer.Ordinal);
for (var i = 0; i < observationKinds.Length; i++)
{
var plugin = ObservationPluginRegistry.GetPluginByKind(observationKinds[i]);
if (plugin?.RequiresHelperClasses == true)
{
_ = kinds.Add(plugin.ObservationKind);
}
}

if (kinds.Count == 0)
{
return default;
}

var ordered = new string[kinds.Count];
kinds.CopyTo(ordered);
return new(ordered);
}

/// <summary>Declares the helper classes for the given observation kinds.</summary>
/// <param name="context">The source production context.</param>
/// <param name="helperKinds">The observation kinds requiring helper declarations, in output order.</param>
/// <param name="features">The consumer compilation's language-feature and generation-option snapshot.</param>
internal static void Generate(
in SourceProductionContext context,
EquatableArray<string> helperKinds,
in LanguageFeatures features)
{
if (helperKinds.Length == 0)
{
return;
}

var sb = PooledBuilder.Rent(helperKinds.Length * PerKindBufferCapacity);
CodeGeneratorHelpers.AppendExtensionClassHeader(sb, features);
AppendHelperDeclarations(sb, helperKinds);
CodeGeneratorHelpers.AppendExtensionClassFooter(sb);
_ = sb.AppendLine();

CodeGeneratorHelpers.AddGeneratedSource(context, HintName, PooledBuilder.ToStringAndReturn(sb), features);
}

/// <summary>Appends the declarations for each of the given observation kinds, in the order given.</summary>
/// <param name="sb">The string builder to append to.</param>
/// <param name="helperKinds">The observation kinds requiring helper declarations.</param>
/// <remarks>
/// A kind no plugin answers to contributes nothing. <see cref="SelectHelperKinds"/> only ever yields kinds
/// it read off a plugin, so the pipeline cannot produce one - but a generator that threw on an unexpected
/// kind would fail the consumer's build rather than merely generate less, which is the worse of the two.
/// </remarks>
internal static void AppendHelperDeclarations(StringBuilder sb, EquatableArray<string> helperKinds)
{
for (var i = 0; i < helperKinds.Length; i++)
{
ObservationPluginRegistry.GetPluginByKind(helperKinds[i])?.EmitHelperClasses(sb);
}
}
}
Loading
Loading