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
230 changes: 230 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# CLAUDE.md — ReactiveUI.SourceGenerators

This document provides guidance for AI assistants and contributors working in this repository.

## Overview

ReactiveUI.SourceGenerators is a Roslyn incremental source-generator package that automates ReactiveUI boilerplate at compile-time. It generates reactive properties, observable-as-property helpers, reactive commands, IViewFor registrations, bindable derived lists, reactive collections, and full reactive-object scaffolding — all with zero runtime reflection, making generated code fully AOT-compatible.

**Minimum consumer requirements:** C# 12.0 · Visual Studio 17.8.0 · ReactiveUI 19.5.31+

## Architecture Overview

The repository ships **three versioned generator assemblies** built from a single shared source folder:

| Project | Roslyn version | Preprocessor constant | Extra features |
|---------|---------------|-----------------------|----------------|
| `ReactiveUI.SourceGenerators.Roslyn480` | 4.8.x (baseline) | _(none)_ | Field-based `[Reactive]`, `[ObservableAsProperty]`, `[ReactiveCommand]`, etc. |
| `ReactiveUI.SourceGenerators.Roslyn4120` | 4.12.0 | `ROSYLN_412` | + partial-property `[Reactive]` and `[ObservableAsProperty]` |
| `ReactiveUI.SourceGenerators.Roslyn5000` | 5.0.0 | `ROSYLN_500` | + same partial-property support on Roslyn 5 |

Each versioned project links all `.cs` files from `ReactiveUI.SourceGenerators.Roslyn/` via:

```xml
<Compile Include="..\ReactiveUI.SourceGenerators.Roslyn\**\*.cs" LinkBase="Shared" />
```

`#if ROSYLN_412 || ROSYLN_500` guards inside the shared source enable partial-property pipelines only on the newer Roslyn builds.

The `ReactiveUI.SourceGenerators` NuGet project packages all three DLLs under separate `analyzers/dotnet/roslyn4.8/cs`, `analyzers/dotnet/roslyn4.12/cs`, and `analyzers/dotnet/roslyn5.0/cs` paths, so NuGet/MSBuild automatically selects the right build based on the host compiler.

Diagnostics are **not** reported by generators. All `RXUISG*` diagnostics live in the separate `ReactiveUI.SourceGenerators.Analyzers.CodeFixes` project.

## Project Structure

```
src/
├── ReactiveUI.SourceGenerators.Roslyn/ # Shared source (linked into all versioned projects)
│ ├── AttributeDefinitions.cs # Injected attribute source texts
│ ├── Reactive/ # [Reactive] generator + Execute + models
│ ├── ReactiveCommand/ # [ReactiveCommand] generator + Execute + models
│ ├── ObservableAsProperty/ # [ObservableAsProperty] generator + Execute + models
│ ├── IViewFor/ # [IViewFor<T>] generator + Execute + models
│ ├── RoutedControlHost/ # [RoutedControlHost] generator
│ ├── ViewModelControlHost/ # [ViewModelControlHost] generator
│ ├── BindableDerivedList/ # [BindableDerivedList] generator
│ ├── ReactiveCollection/ # [ReactiveCollection] generator
│ ├── ReactiveObject/ # [IReactiveObject] generator
│ ├── Diagnostics/ # DiagnosticDescriptors, SuppressionDescriptors
│ └── Core/
│ ├── Extensions/ # ISymbol*, ITypeSymbol*, INamedTypeSymbol*, AttributeData extensions
│ ├── Helpers/ # ImmutableArrayBuilder<T>, EquatableArray<T>, HashCode, etc.
│ └── Models/ # Result<T>, DiagnosticInfo, TargetInfo, etc.
├── ReactiveUI.SourceGenerators.Roslyn480/ # Roslyn 4.8 build (no define)
├── ReactiveUI.SourceGenerators.Roslyn4120/ # Roslyn 4.12 build (ROSYLN_412)
├── ReactiveUI.SourceGenerators.Roslyn5000/ # Roslyn 5.0 build (ROSYLN_500)
├── ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ # Analyzers + code fixers
├── ReactiveUI.SourceGenerators/ # NuGet packaging project (bundles all three DLLs)
├── ReactiveUI.SourceGenerator.Tests/ # TUnit + Verify snapshot tests
├── ReactiveUI.SourceGenerators.Execute*/ # Compile-time execution verification projects
└── TestApps/ # Manual test applications (WPF, WinForms, MAUI, Avalonia)
```

## Code Generation Strategy

All generated C# source is produced using **raw string literals** (`$$"""..."""`). Do **not** use `StringBuilder` or `SyntaxFactory` for code generation.

```csharp
// CORRECT — raw string literal with $$ interpolation
internal static string GenerateProperty(string name, string type) => $$"""
public {{type}} {{name}}
{
get => _{{char.ToLower(name[0])}{{name.Substring(1)}}};
set => this.RaiseAndSetIfChanged(ref _{{char.ToLower(name[0])}{{name.Substring(1)}}}, value);
}
""";

// WRONG — do not use StringBuilder
var sb = new StringBuilder();
sb.AppendLine($"public {type} {name}");
// ...

// WRONG — do not use SyntaxFactory
SyntaxFactory.PropertyDeclaration(...)
```

Raw string literals preserve formatting intent, are trivially diffable in code review, and do not require the overhead of SyntaxFactory node construction.

The injected attribute source texts (in `AttributeDefinitions.cs`) also use `$$"""..."""` raw string literals.

## Roslyn Incremental Pipeline Pattern

Each generator follows this structure:

1. **`Initialize`** — registers post-initialization output (inject attribute source), then calls one or more `Run*` methods.
2. **`Run*`** — builds the `IncrementalValuesProvider` using `ForAttributeWithMetadataName` + a syntax predicate + a semantic extraction function.
3. **`Get*Info` (Execute file)** — stateless extraction function. Returns `Result<TModel?>` with embedded diagnostics. Must be pure; must not capture any `ISymbol` or `SyntaxNode` beyond this call.
4. **`GenerateSource` (Execute file)** — pure function that converts model → raw string source text. No Roslyn symbols allowed here.

```
Initialize()
├─ RegisterPostInitializationOutput → inject attribute definitions
└─ SyntaxProvider.ForAttributeWithMetadataName
├─ syntax predicate (fast, node-type check only)
├─ semantic extraction → Get*Info() → Result<Model>
└─ RegisterSourceOutput → GenerateSource() → AddSource()
```

**Incremental caching rules:**
- All pipeline output models must implement value equality (`record`, `IEquatable<T>`, or `EquatableArray<T>`).
- Never store `ISymbol`, `SyntaxNode`, `SemanticModel`, or `CancellationToken` in a model.
- Use `EquatableArray<T>` (from `Core/Helpers`) instead of `ImmutableArray<T>` in models.

## Generators

| Generator class | Attribute | Input target |
|-----------------|-----------|--------------|
| `ReactiveGenerator` | `[Reactive]` | Field (all Roslyn) or partial property (ROSYLN_412+) |
| `ReactiveCommandGenerator` | `[ReactiveCommand]` | Method |
| `ObservableAsPropertyGenerator` | `[ObservableAsProperty]` | Field or observable method |
| `IViewForGenerator` | `[IViewFor<T>]` | Class |
| `RoutedControlHostGenerator` | `[RoutedControlHost]` | Class |
| `ViewModelControlHostGenerator` | `[ViewModelControlHost]` | Class |
| `BindableDerivedListGenerator` | `[BindableDerivedList]` | Field (`ReadOnlyObservableCollection<T>`) |
| `ReactiveCollectionGenerator` | `[ReactiveCollection]` | Field (`ObservableCollection<T>`) |
| `ReactiveObjectGenerator` | `[IReactiveObject]` | Class |

## Analyzers & Suppressors

All diagnostics use the `RXUISG` prefix. All suppressions use the `RXUISPR` prefix.

| Class | ID range | Purpose |
|-------|----------|---------|
| `PropertyToReactiveFieldAnalyzer` | RXUISG0016 | Suggests converting auto-properties to `[Reactive]` fields |
| `ReactiveAttributeMisuseAnalyzer` | RXUISG0020 | Detects `[Reactive]` on non-partial or non-partial-type members |
| `PropertyToReactiveFieldCodeFixProvider` | — | Converts auto-property → `[Reactive]` field |
| `ReactiveAttributeMisuseCodeFixProvider` | — | Fixes misuse of `[Reactive]` attribute |

Suppressors silence noisy Roslyn/Roslynator diagnostics that are expected for generator-backed patterns (e.g. fields never read, methods that don't need to be static).

### Analyzer Separation (Roslyn Best Practice)

- Generators do **not** report diagnostics — they only call `context.ReportDiagnostic` for internal invariant violations via `DiagnosticInfo` models.
- The `ReactiveUI.SourceGenerators.Analyzers.CodeFixes` project owns all `RXUISG*` diagnostic descriptors and code fixers.
- `DiagnosticDescriptors.cs` and related files are compiled from the shared Roslyn source via the linked `<Compile>` items.

## Testing

### Framework

- **TUnit** — test runner and assertion library (replaces xUnit/NUnit).
- **Verify.SourceGenerators** — snapshot-based verification of generated source output.
- **Microsoft.Testing.Platform** — native test execution (configured via `testconfig.json`).

### Test project targets

The test project multi-targets `net8.0;net9.0;net10.0` (controlled by `$(TestTfms)` in `Directory.Build.props`). Tests run against all three frameworks in CI.

### Snapshot tests

Generator tests extend `TestBase<TGenerator>` and call `TestHelper.TestPass(sourceCode)`. Verify saves `.verified.txt` snapshots in the appropriate subdirectory (`REACTIVE/`, `REACTIVECMD/`, `OAPH/`, `IVIEWFOR/`, `DERIVEDLIST/`, `REACTIVECOLL/`, `REACTIVEOBJ/`).

#### Accepting snapshot changes

1. Enable `VerifierSettings.AutoVerify()` in `ModuleInitializer.cs`.
2. Run `dotnet test --project src/ReactiveUI.SourceGenerator.Tests -c Release`.
3. Disable `VerifierSettings.AutoVerify()`.
4. Re-run tests to confirm all pass without AutoVerify.

### Test source language version

Test source strings are parsed with **CSharp13** (`LanguageVersion.CSharp13`). This is the version used by `TestHelper.RunGeneratorAndCheck`.

### Non-snapshot (unit) tests

Analyzer and helper tests use direct `CSharpCompilation` / `CompilationWithAnalyzers` to verify diagnostics without snapshots. See `PropertyToReactiveFieldAnalyzerTests.cs` for the pattern.

## Common Tasks

### Adding a New Generator

1. Create a value-equatable model record in `Core/Models/` or the generator's own `Models/` folder.
2. Add attribute source text to `AttributeDefinitions.cs` using a `$$"""..."""` raw string literal.
3. Create `<Name>Generator.cs` with `Initialize` wiring up `ForAttributeWithMetadataName`.
4. Create `<Name>Generator.Execute.cs` with `Get*Info` (extraction) and `GenerateSource` (raw string template).
5. Add snapshot tests in `ReactiveUI.SourceGenerator.Tests/UnitTests/`.
6. Accept snapshots using the AutoVerify trick above.

### Adding a New Analyzer Diagnostic

1. Add a `DiagnosticDescriptor` to `DiagnosticDescriptors.cs`.
2. Update `AnalyzerReleases.Unshipped.md`.
3. Implement the analyzer in `ReactiveUI.SourceGenerators.Analyzers.CodeFixes/`.
4. Add unit tests in `ReactiveUI.SourceGenerator.Tests/UnitTests/`.

### Running Tests

```pwsh
dotnet test src/ReactiveUI.SourceGenerator.Tests --configuration Release
```

### Building

```pwsh
dotnet build src/ReactiveUI.SourceGenerators.sln
```

## What to Avoid

- **`ISymbol` / `SyntaxNode` in pipeline output models** — breaks incremental caching; use value-equatable data records instead.
- **`SyntaxFactory` for code generation** — use `$$"""..."""` raw string literals.
- **`StringBuilder` for code generation** — use `$$"""..."""` raw string literals.
- **Diagnostics reported inside generators** — use the separate analyzer project for all `RXUISG*` diagnostics.
- **LINQ in hot Roslyn pipeline paths** — use `foreach` loops (Roslyn convention for incremental generators).
- **Non-value-equatable models** in the incremental pipeline — will defeat caching and cause unnecessary regeneration.
- **APIs unavailable in `netstandard2.0`** inside `ReactiveUI.SourceGenerators.Roslyn*` projects — the generator must run inside the compiler host which targets netstandard2.0.
- **Runtime reflection** in generated code — breaks Native AOT compatibility.
- **`#nullable enable` / nullable annotations in generated output** — these require C# 8+ features; generated code must be compatible with the minimum consumer C# version (12.0).
- **File-scoped namespaces in generated output** — requires C# 10; use block-scoped namespaces.

## Important Notes

- **Required .NET SDKs:** .NET 8.0, 9.0, and 10.0 (all required for multi-targeting the test project).
- **Generator + Analyzer targets:** `netstandard2.0` (Roslyn host requirement).
- **Test project targets:** `net8.0;net9.0;net10.0`.
- **No shallow clones:** The repository uses Nerdbank.GitVersioning; a full `git clone` is required for correct versioning.
- **NuGet packaging:** The `ReactiveUI.SourceGenerators` project bundles all three versioned generator DLLs at different `analyzers/dotnet/roslyn*/cs` paths.
- **Cross-platform tests:** On non-Windows platforms, WPF/WinForms types are injected as source stubs so generator tests compile cross-platform.
- **`SyntaxFactory` helper:** https://roslynquoter.azurewebsites.net/ — useful for inspecting how Roslyn models a given syntax construct (reference only; do not use SyntaxFactory in code-gen paths).

**Philosophy:** Generate zero-reflection, AOT-compatible ReactiveUI boilerplate at compile-time. Separate diagnostic reporting from code generation. Keep the incremental pipeline pure and value-equatable so Roslyn can cache and skip unchanged work.
15 changes: 15 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@
<MauiTfms Condition="$([MSBuild]::IsOSPlatform('Windows'))">net9.0-android;net9.0-ios;net9.0-maccatalyst;net9.0-windows10.0.19041.0;net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0-windows10.0.19041.0</MauiTfms>
</PropertyGroup>

<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
<TUnitImplicitUsings>true</TUnitImplicitUsings>
<TUnitAssertionsImplicitUsings>true</TUnitAssertionsImplicitUsings>
<OutputType>Exe</OutputType>
</PropertyGroup>

<!-- MTP Native JSON Configuration -->
<ItemGroup Condition="'$(IsTestProject)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)testconfig.json">
<Link>$(AssemblyName).testconfig.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup Condition="'$(IsTestProject)' != 'true'">
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
</ItemGroup>
Expand Down
20 changes: 3 additions & 17 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.102" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
<PackageVersion Include="NuGet.Common" Version="6.14.0" />
<PackageVersion Include="NuGet.Protocol" Version="6.14.0" />
<PackageVersion Include="stylecop.analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
Expand All @@ -26,25 +24,13 @@
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.8" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.13" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.13" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageVersion Include="ReactiveUI.Maui" Version="23.1.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="System.Formats.Asn1" Version="10.0.0" />
<PackageVersion Include="NUnit" Version="4.5.1" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageVersion Include="NUnit.Analyzers" Version="4.12.0" />
<PackageVersion Include="Microsoft.Reactive.Testing" Version="6.1.0" />
<PackageVersion Include="System.Formats.Asn1" Version="10.0.5" />
<PackageVersion Include="PublicApiGenerator" Version="11.5.4" />
<PackageVersion Include="coverlet.msbuild" Version="8.0.1" />
<PackageVersion Include="Verify.NUnit" Version="31.13.5" />
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0" />
<PackageVersion Include="ReactiveMarbles.SourceGenerator.TestNuGetHelper" Version="1.3.1" />
<PackageVersion Include="Basic.Reference.Assemblies.Net80Windows" Version="1.8.4" />
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.4" />
<PackageVersion Include="Basic.Reference.Assemblies.Net90" Version="1.8.4" />
<PackageVersion Include="Basic.Reference.Assemblies.Net80" Version="1.8.4" />
<PackageVersion Include="Splat" Version="19.3.1" />
<PackageVersion Include="ReactiveUI" Version="23.2.1" />
<PackageVersion Include="ReactiveUI.Maui" Version="23.2.1" />
<PackageVersion Include="System.Reactive" Version="6.1.0" />
<PackageVersion Include="TUnit" Version="1.19.22" />
<PackageVersion Include="Verify.TUnit" Version="31.13.2" />
Expand Down
7 changes: 0 additions & 7 deletions src/ReactiveUI.SourceGenerator.Tests/AssemblyInfo.Parallel.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//HintName: TestNs.TestVM.BindableDerivedList.g.cs
// <auto-generated/>
using System.Collections.ObjectModel;
using DynamicData;
using ReactiveUI;

#pragma warning disable
#nullable enable

namespace TestNs
{

public partial class TestVM
{
/// <inheritdoc cref="_test1"/>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::System.Collections.ObjectModel.ReadOnlyObservableCollection<int> Test1 => _test1;
}
}
#nullable restore
#pragma warning restore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//HintName: TestNs.TestVM.BindableDerivedList.g.cs
// <auto-generated/>
using System.Collections.ObjectModel;
using DynamicData;
using ReactiveUI;

#pragma warning disable
#nullable enable

namespace TestNs
{

public partial class TestVM
{
/// <inheritdoc cref="_items"/>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::System.Collections.ObjectModel.ReadOnlyObservableCollection<global::TestNs.ItemModel>? Items => _items;
}
}
#nullable restore
#pragma warning restore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//HintName: TestNs.TestVM.BindableDerivedList.g.cs
// <auto-generated/>
using System.Collections.ObjectModel;
using DynamicData;
using ReactiveUI;

#pragma warning disable
#nullable enable

namespace TestNs
{

public partial class TestVM
{
/// <inheritdoc cref="_dates"/>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::System.Collections.ObjectModel.ReadOnlyObservableCollection<global::System.DateTime>? Dates => _dates;
/// <inheritdoc cref="_timestamps"/>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::System.Collections.ObjectModel.ReadOnlyObservableCollection<global::System.DateTimeOffset>? Timestamps => _timestamps;
}
}
#nullable restore
#pragma warning restore
Loading
Loading