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
1 change: 0 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
],
"groupName": "test tooling",
"matchPackageNames": [
"/^NSubstitute(\\.|$)/",
"/^BenchmarkDotNet(\\.|$)/"
]
}
Expand Down
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ src/
│ ├── PooledStringBuilder.cs # char[]-backed builder for generated fragments
│ └── RuntimeFlavourRewriter.cs # Retargets output onto the .Reactive package
├── ReactiveUI.Binding.SourceGenerators.Roslyn413/ # The same generator source against Roslyn 4.13
├── ReactiveUI.Binding.Analyzer.Roslyn413/ # The same analyzer source against Roslyn 4.13
├── ReactiveUI.Binding.Analyzer/ # Roslyn analyzer (netstandard2.0)
│ └── Analyzers/
│ ├── BindingInvocationAnalyzer.cs # RXUIBIND001, 003, 004, 005, 006, 007, 008
Expand Down Expand Up @@ -515,6 +518,7 @@ Not all platforms support before-change notifications (WPF DP, WinUI DP, WinForm
| RXUIBIND008 | Warning | Property does not implement IInteraction |
| RXUIBIND009 | Warning | Generated binding dispatch is out of reach from this file |
| RXUIBIND010 | Warning | Observed path passes through a type that raises no notification |
| RXUIBIND011 | Warning | Binding call resolved to ReactiveUI's own mixin |

## Code Style & Quality Requirements

Expand Down Expand Up @@ -743,9 +747,9 @@ build keeps working right up until Wine starts. Each copy chains to the reposito
- **Runtime library targets:** net8.0;net9.0;net10.0;net462;net472;net481
- **No shallow clones:** Repository requires full clone for Nerdbank.GitVersioning
- **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
and analyzer DLLs into `analyzers/dotnet/roslyn4.8/cs` and `analyzers/dotnet/roslyn4.13/cs`, so referencing a
runtime package is all a consumer needs. `ReactiveUI.Binding.SourceGenerators` is a compatibility package that
ships only the MSBuild props and targets: 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.
73 changes: 56 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ generation. Zero reflection, fully AOT/trimming safe, 3-7x faster than the legac

- [What does it do?](#what-does-it-do)
- [How does it work?](#how-does-it-work)
- [How a call site reaches its generated code](#how-a-call-site-reaches-its-generated-code)
- [How do I install?](#how-do-i-install)
- [Supported APIs](#supported-apis)
- [Usage Examples](#usage-examples)
Expand Down Expand Up @@ -61,8 +62,9 @@ generation. Zero reflection, fully AOT/trimming safe, 3-7x faster than the legac
## What does it do?

ReactiveUI.Binding.SourceGenerators is an incremental source generator that analyses your `WhenChanged`, `WhenChanging`,
`WhenAnyValue`, `WhenAny`, `WhenAnyObservable`, `BindOneWay`, `BindTwoWay`, `OneWayBind`, and `Bind` call sites at
compile time and emits optimised, strongly-typed observation and binding code. It eliminates:
`WhenAnyValue`, `WhenAny`, `WhenAnyObservable`, `BindOneWay`, `BindTwoWay`, `OneWayBind`, `Bind`, `BindTo`,
`BindCommand`, and `BindInteraction` call sites at compile time and emits optimised, strongly-typed observation and
binding code. It eliminates:

- **Runtime expression-tree compilation** -- no `Expression<Func<T>>` evaluation at runtime
- **Reflection** -- all property access is generated as direct member access
Expand All @@ -80,32 +82,61 @@ WPF DependencyObject, WinUI DependencyObject, Apple KVO, WinForms Component, And
observation factories via a `[ModuleInitializer]`.

**Pipeline B (Invocation Detection)** scans method invocations and extracts lambda property paths at compile time. Each
call site is identified by `[CallerFilePath]` + `[CallerLineNumber]`, and the generator emits a per-call-site optimised
method that is dispatched to at runtime via a generated lookup table.
call site gets its own optimised method with direct property access:

```csharp
// You write:
var obs = vm.WhenChanged(x => x.Name);

// The generator emits a dispatch stub that captures caller info:
public static IObservable<TReturn> WhenChanged<TObj, TReturn>(
this TObj obj, Expression<Func<TObj, TReturn>> property,
[CallerFilePath] string callerFilePath = "",
[CallerLineNumber] int callerLineNumber = 0) where TObj : class
{
if (__GeneratedBindingDispatcher.TryGetWhenChanged(callerFilePath, callerLineNumber, obj, out var result))
return (IObservable<TReturn>)result!;
throw new InvalidOperationException("No generated binding found.");
}

// And a per-call-site method with direct property access:
// The generator emits a per-call-site method with direct property access:
private static IObservable<string> __WhenChanged_0(MyViewModel obj)
{
return new PropertyObservable<string>(
obj, "Name", static o => ((MyViewModel)o).Name, true);
}
```

## How a call site reaches its generated code

Two mechanisms. Your compiler picks one.

**Roslyn 4.13 or newer: the call is intercepted.** The generator points the compiler at your exact call and says "run
this instead". No name lookup is involved, so it works from any file and any language version - including
`<LangVersion>7.3</LangVersion>` and .NET Framework 4.6.2:

```csharp
[InterceptsLocation(1, "j8MnGMWiKja+66BWQ5M81agPAABQcm9ncmFtLmNz")] // vm.WhenChanged(x => x.Name) on line 12
internal static IObservable<string> __Intercept_WhenChanged_7FFF(
this MyViewModel objectToMonitor,
Expression<Func<MyViewModel, string>> property1, /* caller-info parameters */)
=> __WhenChanged_7FFF(objectToMonitor);
```

**Roslyn 4.8 to 4.12: a concrete overload competes for the call.** It beats the generic runtime stub because a
non-generic method wins overload resolution - but only when extension-method lookup finds it. RXUIBIND009 warns when it
will not.

Either way the same generated method runs, so bindings behave identically.

### What the package ships

The generator and its analyzer are packed once per compiler generation:

```
analyzers/dotnet/roslyn4.8/cs/ <- Roslyn 4.8 - 4.12
analyzers/dotnet/roslyn4.13/cs/ <- Roslyn 4.13+
```

The .NET SDK picks the highest folder your compiler supports. A legacy non-SDK project is handed both, so the package's
targets delete the one you are not being served by - the generator never runs twice.

Two knobs:

| Setting | Effect |
|--------------------------------------------------------|--------------------------------------------------------------|
| `<ReactiveUIBindingUseInterceptors>false</...>` | Use the overloads even on a compiler that could intercept |
| Roslyn older than 4.8 | Build fails with **RXUIBIND100** rather than silently generating nothing |

## How do I install?

Install the `ReactiveUI.Binding` NuGet package. The source generator is automatically included.
Expand Down Expand Up @@ -152,6 +183,7 @@ Platform-specific packages provide DependencyProperty observation and other plat
| `BindTwoWay` | Two-way binding between source and target |
| `OneWayBind` | ReactiveUI compatibility shim for one-way binding |
| `Bind` | ReactiveUI compatibility shim for two-way binding |
| `BindTo` | Apply an observable stream to a target property |
| `BindCommand` | Bind a command property to a UI element |
| `BindInteraction` | Bind an interaction to a handler |

Expand Down Expand Up @@ -480,8 +512,15 @@ The separate analyzer package reports the following diagnostics:
| RXUIBIND006 | Warning | Expression contains an unsupported path segment (indexer, field, or method call). Only simple property access chains can be observed by the source generator. |
| RXUIBIND007 | Warning | BindCommand control has no bindable event. Specify the `toEvent` parameter explicitly. |
| RXUIBIND008 | Warning | The property selected in a BindInteraction expression does not implement `IInteraction<TInput, TOutput>`. |
| RXUIBIND009 | Warning | The generated binding dispatch is out of reach from this file, so the call falls back to the runtime stub. |
| RXUIBIND009 | Warning | The generated binding dispatch is out of reach from this file, so the call falls back to the runtime stub. Not reported where the call site is claimed by an interceptor. |
| RXUIBIND010 | Warning | The observed path passes through a type that raises no notification, so it is read once and the observation stops following the path there. |
| RXUIBIND011 | Warning | The call resolved to ReactiveUI's own mixin, so nothing is generated for it and it takes the runtime expression engine. Import `ReactiveUI.Binding` in the file. |

The package's own targets report one build error of their own:

| ID | Description |
|-------------|-----------------------------------------------------------------------------------------------------------------|
| RXUIBIND100 | The compiler building the project is older than the oldest analyzer slot, so no generator would be loaded at all. |

## Where this differs from ReactiveUI

Expand Down
21 changes: 11 additions & 10 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@

<!-- MAUI version varies by target framework -->
<PropertyGroup>
<MauiVersion Condition="$(TargetFramework.StartsWith('net10'))">10.0.100</MauiVersion>
<MauiVersion Condition="$(TargetFramework.StartsWith('net11'))">11.0.0-preview.7.26406.9</MauiVersion>
<MauiVersion Condition="$(TargetFramework.StartsWith('net10'))">10.0.101</MauiVersion>
<MauiVersion Condition="$(TargetFramework.StartsWith('net11'))">11.0.0-rc.1.26451.6</MauiVersion>
</PropertyGroup>

<PropertyGroup>
<!-- StyleSharp.Analyzers, PerformanceSharp.Analyzers and SecuritySharp.Analyzers ship from the
same release pipeline and always share a version. -->
<RoslynCommonAnalyzersVersion>3.46.1</RoslynCommonAnalyzersVersion>
<RoslynCommonAnalyzersVersion>3.46.2</RoslynCommonAnalyzersVersion>
</PropertyGroup>

<ItemGroup>
<!-- Testing Framework -->
<PackageVersion Include="NSubstitute" Version="6.2.0"/>
<PackageVersion Include="TUnit" Version="1.66.16"/>
<PackageVersion Include="TUnit" Version="1.66.27"/>
<PackageVersion Include="Verify.TUnit" Version="32.0.0"/>
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0"/>

Expand All @@ -37,15 +36,17 @@
run against; it must stay at or above what their own tooling requires
(Basic.Reference.Assemblies needs >= 4.11, BenchmarkDotNet needs >= 4.14).
Running the generator on a newer Roslyn than it was built against is the
normal, supported direction.
normal, supported direction, and it is also what lets the suite name a
language version by its constant rather than as whatever preview happens
to be current.

Microsoft.CodeAnalysis.Analyzers is a build-time-only analyzer package
(the RS#### authoring rules) with no impact on the end-user-facing Roslyn
version, so it tracks the latest stable release independently.
-->
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0"/>

<!-- Test Infrastructure -->
<PackageVersion Include="Basic.Reference.Assemblies.Net110" Version="1.8.11"/>
Expand All @@ -65,8 +66,8 @@
<PackageVersion Include="PerformanceSharp.Analyzers" Version="$(RoslynCommonAnalyzersVersion)"/>
<PackageVersion Include="SecuritySharp.Analyzers" Version="$(RoslynCommonAnalyzersVersion)"/>
<PackageVersion Include="Roslynator.Analyzers" Version="5.0.0"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.400"/>
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.33.0.1635"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401"/>
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.34.0.3385"/>
<PackageVersion Include="Blazor.Common.Analyzers" Version="2.1.0"/>

<!-- MAUI -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyName>ReactiveUI.Binding.Analyzer</AssemblyName>
<RootNamespace>ReactiveUI.Binding.Analyzer</RootNamespace>
<IsPackable>false</IsPackable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<!-- RS1038: Workspaces reference is required for code fix providers in this assembly -->
<NoWarn>$(NoWarn);RS1038</NoWarn>
</PropertyGroup>

<!-- The analyzer travels in the same slot as the generator, so it is compiled against the same compiler
and answers the same question about interception at compile time: a diagnostic about the reach of a
dispatch overload is only meaningful where dispatch overloads are what gets emitted. -->
<PropertyGroup>
<DefineConstants>$(DefineConstants);ROSLYN_4_13</DefineConstants>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" VersionOverride="4.13.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" VersionOverride="4.13.0"/>
</ItemGroup>

<!-- The same analyzer source, compiled a second time against the newer compiler. -->
<ItemGroup>
<Compile Include="..\ReactiveUI.Binding.Analyzer\**\*.cs"
Exclude="..\ReactiveUI.Binding.Analyzer\bin\**\*.cs;..\ReactiveUI.Binding.Analyzer\obj\**\*.cs"
LinkBase="Analyzer"/>
<Compile Include="..\ReactiveUI.Binding.SourceGenerators\DiagnosticWarnings.cs" Link="DiagnosticWarnings.cs"/>
<Compile Include="..\ReactiveUI.Binding.SourceGenerators\Constants.cs" Link="Constants.cs"/>
<Compile Include="..\ReactiveUI.Binding.SourceGenerators\Models\InterceptorLocation.cs" Link="Models\InterceptorLocation.cs"/>
<Compile Include="..\ReactiveUI.Binding.SourceGenerators\Helpers\InterceptableLocationReader.cs" Link="Helpers\InterceptableLocationReader.cs"/>
<Compile Include="..\ReactiveUI.Binding.Shared\Polyfills\**.cs" Link="Polyfills"/>
<Compile Include="..\ReactiveUI.Binding.Shared\Helpers\ArgumentExceptionHelper.cs" Link="Helpers\ArgumentExceptionHelper.cs"/>
</ItemGroup>

<!-- Release tracking reads these through AdditionalFiles. The package auto-includes them from the project
directory, which does not reach a linked copy, so they are named explicitly. -->
<ItemGroup>
<AdditionalFiles Include="..\ReactiveUI.Binding.Analyzer\AnalyzerReleases.Shipped.md" Link="AnalyzerReleases.Shipped.md"/>
<AdditionalFiles Include="..\ReactiveUI.Binding.Analyzer\AnalyzerReleases.Unshipped.md" Link="AnalyzerReleases.Unshipped.md"/>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="ReactiveUI.Binding.Analyzer.Tests"/>
<InternalsVisibleTo Include="ReactiveUI.Binding.Analyzer.Tests.Roslyn413"/>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
RXUIBIND008 | Usage | Warning | Property is not an IInteraction
RXUIBIND009 | Usage | Warning | Generated binding dispatch is out of reach for this file
RXUIBIND010 | Usage | Warning | Observed path passes through a type that raises no notification
RXUIBIND011 | Usage | Warning | Binding call resolved to ReactiveUI's own mixin
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ public class BindingInvocationAnalyzer : DiagnosticAnalyzer
/// <summary>The parameter type a property path arrives as, which marks it out from the other arguments.</summary>
private const string ExpressionParameterTypePrefix = "System.Linq.Expressions.Expression<";

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(
/// <summary>The diagnostics this analyzer reports.</summary>
private static readonly ImmutableArray<DiagnosticDescriptor> ReportedDiagnostics =
new[]
{
DiagnosticWarnings.NonInlineLambda,
DiagnosticWarnings.PrivateMember,
DiagnosticWarnings.NoBeforeChangeSupport,
DiagnosticWarnings.ValidationNotGenerated,
DiagnosticWarnings.UnsupportedPathSegment,
DiagnosticWarnings.NoBindableEvent,
DiagnosticWarnings.InvalidInteractionType,
DiagnosticWarnings.SilentPathLink);
DiagnosticWarnings.SilentPathLink,
}.ToImmutableArray();

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ReportedDiagnostics;

/// <inheritdoc/>
public override void Initialize(AnalysisContext context)
Expand Down
Loading
Loading