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
35 changes: 28 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,16 @@ src/
├── 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
│ ├── DispatchReachAnalyzer.cs # RXUIBIND009
│ ├── ToPropertyAnalyzer.cs # RXUIBIND012, 013
│ ├── ToPropertyInitialValueAnalyzer.cs # RXUIBIND014
│ ├── UnreachableTypeAnalyzer.cs # RXUIBIND015, 016
│ └── TypeAnalyzer.cs # RXUIBIND002
│ ├── Analyzers/
│ │ ├── BindingInvocationAnalyzer.cs # RXUIBIND001, 003, 004, 005, 006, 007, 008
│ │ ├── DispatchReachAnalyzer.cs # RXUIBIND009
│ │ ├── ObservableAsPropertyAnalyzer.cs # RXUIBIND018, 019
│ │ ├── ToPropertyAnalyzer.cs # RXUIBIND012, 013
│ │ ├── ToPropertyInitialValueAnalyzer.cs # RXUIBIND014
│ │ ├── UnreachableTypeAnalyzer.cs # RXUIBIND015, 016
│ │ └── TypeAnalyzer.cs # RXUIBIND002
│ └── CodeFixes/
│ └── ObservableAsPropertyCodeFixProvider.cs # RXUIBIND018: older [ObservableAsProperty] forms to partial properties
│
├── benchmarks/
│ ├── ReactiveUI.Binding.Benchmarks/ # Runtime binding benchmarks
Expand Down Expand Up @@ -365,6 +368,15 @@ constructor resolves to null. `[ExcludeFromViewRegistration]` leaves a view out.
A view is skipped when it, or a type it is nested in, is an open generic. A closed subclass of a generic view
base registers through its own `IViewFor<T>` interface.

Both runtime flavours are matched: `ReactiveUI.Binding.IViewFor<T>` and `ReactiveUI.Binding.Reactive.IViewFor<T>`.
The view attributes are read from the flavour of the interface the view implements.

ReactiveUI.SourceGenerators' `[IViewFor<T>]` and `[IViewFor("TypeName")]` also register a view. That generator adds
`IViewFor<T>` to the class in output no other generator can see, so the attributes are matched by metadata name through
`ForAttributeWithMetadataName`. The type name is bound at the class's position, so usings, `nameof` and generic names
resolve. The resolver casts the view through `object` with `as`, because the class may not implement the interface. A
view found both ways registers once. A `RegistrationType` of `LazySingleton` or `Constant` is a `[SingleInstanceView]`.

The generated class registers the lookup with `DefaultViewLocator.SetGeneratedViewDispatch`. From C# 9 it does
so in a module initializer, which runs before any code in the assembly. The generator declares
`ModuleInitializerAttribute` when the framework has none. Older projects register in a static constructor, which
Expand Down Expand Up @@ -757,6 +769,13 @@ writes the property's body and a `_{name}Helper` field, and the consumer assigns
consumer declares the property, so every generator in the build sees it. A property written from a field would
exist only in generated code, which no other generator can observe or bind, so that form is not offered.

The attribute's `InitialValue`, `ReadOnly` and `UseProtected` shape the generated members. A non-string
`InitialValue` is an expression held in a backing field. The generated file repeats the declaring file's `using` and
`extern alias` directives, so the expression binds as it was written. The attribute is also valid on a field, a method
and a property, the forms of ReactiveUI's older generator, so that code compiles. RXUIBIND018 reports them, and
`ObservableAsPropertyCodeFixProvider` rewrites them as partial properties; `PropertyName` and `Inheritance` exist only
for that rewrite.

### One Body Per Reachable Branch

The two dispatch mechanisms differ in what they can tell apart, and the emitted bodies follow. File-and-line
Expand Down Expand Up @@ -884,6 +903,8 @@ Not all platforms support before-change notifications (WPF DP, WinUI DP, WinForm
| RXUIBIND015 | Warning | Binding call names a type generated code cannot reach |
| RXUIBIND016 | Warning | Binding call is made through a type parameter |
| RXUIBIND017 | Warning | Binding writes to a UI object without its platform package |
| RXUIBIND018 | Warning | ObservableAsProperty needs a partial get-only property |
| RXUIBIND019 | Warning | ObservableAsProperty method takes parameters |

## Code Style & Quality Requirements

Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,29 @@ public partial class PersonViewModel : INotifyPropertyChanged
The attribute ships in the runtime package, so the generator adds no types to your assembly. Two projects that see
each other's internals both keep working.

The attribute takes these options.

| Option | Effect |
|--------|--------|
| `InitialValue` | What the property returns until you assign its helper. For a `string` property it is the value, such as `"none"`. For any other type it is a C# expression, such as `"1.5d"`, evaluated once. A non-nullable `string` property with no initial value returns `string.Empty`. |
| `ReadOnly` | Makes the helper field `readonly`, so only a constructor can assign it. |
| `UseProtected` | Makes the helper field `protected` rather than `private`, so a derived type can assign it. |

```csharp
[ObservableAsProperty(InitialValue = "Loading", ReadOnly = true)]
public partial string Status { get; }
```

ReactiveUI's older source generator also accepted `[ObservableAsProperty]` on a field, on a method that returns an
observable, and on an observable property. Nothing is generated for those forms. RXUIBIND018 reports each one, with a
code fix that rewrites it as a partial property:

- A field becomes a property named after it, without its `_` or `m_` prefix. Its initializer becomes `InitialValue`, and
its `Inheritance` becomes a `virtual`, `override` or `new` modifier.
- A method or an observable property keeps its declaration and gains a property named `PropertyName`, or its own name
followed by `Property`. The fix writes an `InitializeOAPH` method that assigns each helper, which your constructor
already calls.

### Observing a path built at run time

`WhenAnyDynamic` takes the path as an `Expression`, not a lambda. So you can build the path from a property name
Expand All @@ -581,7 +604,7 @@ path before you build, `WhenChanged` and `WhenAny` observe the same thing withou
## The view locator

A view locator finds the view for a view model. Implement `IViewFor<T>`, and the generator registers the view for
you.
you. This works with both the `ReactiveUI.Binding` and `ReactiveUI.Binding.Reactive` packages.

```csharp
public class LoginView : IViewFor<LoginViewModel>
Expand All @@ -606,6 +629,11 @@ Three attributes change what is registered.
| `[SingleInstanceView]` | Keeps one instance instead of creating a view each time. Do not use it for a view that appears more than once in the tree. |
| `[ExcludeFromViewRegistration]` | Leaves the view out of the generated registration. |

A view that ReactiveUI.SourceGenerators completes with `[IViewFor<T>]` or `[IViewFor("TypeName")]` is registered too.
That generator adds `IViewFor<T>` to the class, so the generated lookup checks for the interface when it resolves the
view. A class it leaves alone resolves to null. A `RegistrationType` of `LazySingleton` or `Constant` keeps one
instance, created the first time the view resolves.

```csharp
[ViewContract("compact")]
public class CompactDashboardView : IViewFor<DashboardViewModel> { }
Expand Down Expand Up @@ -803,6 +831,8 @@ The analyzer ships inside the runtime packages. It reports these diagnostics.
| RXUIBIND015 | Warning | The call names a private or protected nested type, which generated code cannot reach, so nothing is generated and the call throws. Make the type `internal` or `public`, or call the `Unsafe` overload. |
| RXUIBIND016 | Warning | The call is made through a type parameter of the calling code, so generated code cannot name its types and the call throws. Call the `Unsafe` overload. |
| RXUIBIND017 | Warning | The binding writes to a WPF, WinForms or MAUI object, but the matching `ReactiveUI.Binding.Wpf`, `.WinForms` or `.Maui` package is not referenced, so writes from another thread are not marshalled onto the object's thread. Reference the platform package. |
| RXUIBIND018 | Warning | `[ObservableAsProperty]` marks something other than a partial get-only instance property, so nothing is generated. For a field, a method or an observable property, a code fix rewrites it as a partial property (C# 13). |
| RXUIBIND019 | Warning | A method marked `[ObservableAsProperty]` takes parameters, so it cannot supply a property's values. |

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

Expand Down
2 changes: 2 additions & 0 deletions src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@
RXUIBIND015 | Usage | Warning | Binding call names a type generated code cannot reach
RXUIBIND016 | Usage | Warning | Binding call is made through a type parameter
RXUIBIND017 | Usage | Warning | Binding writes to a UI object without its platform package
RXUIBIND018 | Usage | Warning | ObservableAsProperty needs a partial get-only property
RXUIBIND019 | Usage | Warning | ObservableAsProperty method takes parameters
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors 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 Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using ReactiveUI.Binding.Helpers;
using ReactiveUI.Binding.SourceGenerators;

namespace ReactiveUI.Binding.Analyzer.Analyzers;

/// <summary>
/// Reports an <c>[ObservableAsProperty]</c> the generator writes nothing for (RXUIBIND018), and a method marked with it
/// that takes parameters (RXUIBIND019).
/// </summary>
/// <remarks>
/// The generator only writes the body of a partial get-only instance property. ReactiveUI's older source generator also
/// accepted a field, a method and an observable property; the diagnostic for each of those carries the form in its
/// properties, so the code fix knows how to rewrite it.
/// </remarks>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ObservableAsPropertyAnalyzer : DiagnosticAnalyzer
{
/// <summary>The diagnostic property naming the form the code fix rewrites.</summary>
internal const string FormProperty = "Form";

/// <summary>The form of a field, whose value the property takes over.</summary>
internal const string FieldForm = "Field";

/// <summary>The form of a method returning the observable the property is built from.</summary>
internal const string MethodForm = "Method";

/// <summary>The form of a property returning the observable the property is built from.</summary>
internal const string ObservablePropertyForm = "ObservableProperty";

/// <summary>The attribute's metadata name in the lean runtime.</summary>
internal const string LeanAttributeName = "ReactiveUI.Binding.ObservableAsPropertyAttribute";

/// <summary>The attribute's metadata name in the System.Reactive runtime.</summary>
internal const string ReactiveAttributeName = "ReactiveUI.Binding.Reactive.ObservableAsPropertyAttribute";

/// <summary>The diagnostics this analyzer reports.</summary>
private static readonly ImmutableArray<DiagnosticDescriptor> ReportedDiagnostics =
new[] { DiagnosticWarnings.ObservableAsPropertyNeedsPartialProperty, DiagnosticWarnings.ObservableAsPropertyMethodHasParameters }.ToImmutableArray();

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

/// <inheritdoc/>
public override void Initialize(AnalysisContext context)
{
ArgumentExceptionHelper.ThrowIfNull(context);
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(static start =>
{
var lean = start.Compilation.GetTypeByMetadataName(LeanAttributeName);
var reactive = start.Compilation.GetTypeByMetadataName(ReactiveAttributeName);
if (lean is null && reactive is null)
{
return;
}

var observable = start.Compilation.GetTypeByMetadataName("System.IObservable`1");
start.RegisterSymbolAction(
symbolContext => AnalyzeSymbol(in symbolContext, lean, reactive, observable),
SymbolKind.Field,
SymbolKind.Method,
SymbolKind.Property);
});
}

/// <summary>Finds the <c>[ObservableAsProperty]</c> on a member, in either runtime flavour.</summary>
/// <param name="symbol">The member.</param>
/// <param name="lean">The lean runtime's attribute type, or null when it is not referenced.</param>
/// <param name="reactive">The System.Reactive runtime's attribute type, or null when it is not referenced.</param>
/// <returns>The attribute, or null when the member has none.</returns>
internal static AttributeData? FindAttribute(ISymbol symbol, INamedTypeSymbol? lean, INamedTypeSymbol? reactive)
{
var attributes = symbol.GetAttributes();
for (var i = 0; i < attributes.Length; i++)
{
var type = attributes[i].AttributeClass;
if (SymbolEqualityComparer.Default.Equals(type, lean) || SymbolEqualityComparer.Default.Equals(type, reactive))
{
return attributes[i];
}
}

return null;
}

/// <summary>Returns the value type of an <c>IObservable&lt;T&gt;</c>.</summary>
/// <param name="type">The type to check.</param>
/// <param name="observable">The open <c>IObservable&lt;T&gt;</c>, or null when it does not resolve.</param>
/// <returns>The type argument, or null when the type is not an <c>IObservable&lt;T&gt;</c>.</returns>
internal static ITypeSymbol? ObservedType(ITypeSymbol type, INamedTypeSymbol? observable) =>
type is INamedTypeSymbol { TypeArguments: [var argument] } named
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, observable)
? argument
: null;

/// <summary>Checks whether a property is declared the way the generator implements: a partial get-only instance property.</summary>
/// <param name="property">The property.</param>
/// <returns><see langword="true"/> when the generator writes its body.</returns>
internal static bool IsGeneratedForm(IPropertySymbol property)
{
if (property.IsStatic)
{
return false;
}

var references = property.DeclaringSyntaxReferences;
for (var i = 0; i < references.Length; i++)
{
if (references[i].GetSyntax() is PropertyDeclarationSyntax
{
ExpressionBody: null,
AccessorList.Accessors: [{ RawKind: (int)SyntaxKind.GetAccessorDeclaration, Body: null, ExpressionBody: null }],
} declaration
&& declaration.Modifiers.Any(SyntaxKind.PartialKeyword))
{
return true;
}
}

return false;
}

/// <summary>Reports a marked member the generator writes nothing for.</summary>
/// <param name="context">The symbol analysis context.</param>
/// <param name="lean">The lean runtime's attribute type, or null when it is not referenced.</param>
/// <param name="reactive">The System.Reactive runtime's attribute type, or null when it is not referenced.</param>
/// <param name="observable">The open <c>IObservable&lt;T&gt;</c>, or null when it does not resolve.</param>
private static void AnalyzeSymbol(in SymbolAnalysisContext context, INamedTypeSymbol? lean, INamedTypeSymbol? reactive, INamedTypeSymbol? observable)
{
var symbol = context.Symbol;
if (FindAttribute(symbol, lean, reactive) is null)
{
return;
}

string? form;
switch (symbol)
{
case IPropertySymbol property when IsGeneratedForm(property):
{
return;
}

case IPropertySymbol property:
{
form = !property.IsStatic && ObservedType(property.Type, observable) is not null ? ObservablePropertyForm : null;
break;
}

case IMethodSymbol { Parameters.Length: > 0 }:
{
context.ReportDiagnostic(Diagnostic.Create(DiagnosticWarnings.ObservableAsPropertyMethodHasParameters, symbol.Locations[0], symbol.Name));
return;
}

case IMethodSymbol method:
{
form = !method.IsStatic && ObservedType(method.ReturnType, observable) is not null ? MethodForm : null;
break;
}

default:
{
form = symbol.IsStatic ? null : FieldForm;
break;
}
}

var properties = form is null
? ImmutableDictionary<string, string?>.Empty
: ImmutableDictionary<string, string?>.Empty.Add(FormProperty, form);
context.ReportDiagnostic(Diagnostic.Create(DiagnosticWarnings.ObservableAsPropertyNeedsPartialProperty, symbol.Locations[0], properties, symbol.Name));
}
}
Loading
Loading