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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ copies (CS0436).
read it from source, so a consumer keeps no reference to the attributes assembly.
- Generators, analyzers and code fixes stay `netstandard2.0`, the Roslyn host's framework. Only the attributes library
targets `$(LibraryTfms)`.
- Never emit a call ReactiveUI.Binding has to intercept (`WhenAny*`, `Bind`, `OneWayBind`, `BindCommand`): its
generator cannot see another generator's output, so the call has no dispatch and throws at run time. Generated code
that follows properties uses Binding's non-intercepted `ObservedProperty` when `ViewApiRules.HasObservedProperty`
finds it (ReactiveUI.Binding 8.4.0 or later), and its own `PropertyChanged` following otherwise.

Generators report only the `RXUISG*` diagnostics about input they cannot generate from (see
[Analyzer Separation](#analyzer-separation-roslyn-best-practice)). Diagnostics about how code should be written, and
Expand Down Expand Up @@ -173,6 +177,7 @@ All diagnostics use the `RXUISG` prefix. All suppressions use the `RXUISPR` pref
| `PropertyToReactiveFieldAnalyzer` | RXUISG0016 | Suggests converting auto-properties to `[Reactive]` properties |
| `ReactiveAttributeMisuseAnalyzer` | RXUISG0020 | Detects `[Reactive]` on non-partial or non-partial-type members |
| `ReactiveCommandAnalyzer` | RXUISG0002, RXUISG0008, RXUISG0021 | Reports `[ReactiveCommand]` methods and schedulers the generator skips |
| `ControlHostAnalyzer` | RXUISG0022 | Reports WinForms hosts generated without ReactiveUI.Binding 8.4.0's `ObservedProperty` (Info) |
| `PropertyToReactiveFieldCodeFixProvider` | — | Converts auto-property → `[Reactive]` partial property (C# 13+; C# 14+ with an initializer), else a `[Reactive]` field |
| `ReactiveAttributeMisuseCodeFixProvider` | — | Fixes misuse of `[Reactive]` attribute |

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,9 @@ now covers three features this package used to generate. They have been removed
| RXUISG0014, RXUISG0017 and the RXUISPR0002 suppression | Nothing: they only applied to `[ObservableAsProperty]` |

The Windows Forms `[RoutedControlHost]` and `[ViewModelControlHost]` remain. They resolve views through ReactiveUI's
view locator, or through ReactiveUI.Binding's when the project's ReactiveUI is built on it.
view locator, or through ReactiveUI.Binding's when the project's ReactiveUI is built on it. With ReactiveUI.Binding
8.4.0 or later they follow their own properties through Binding's `ObservedProperty`, with `WhenAnyValue`'s semantics;
with anything older they follow them through `PropertyChanged`, and RXUISG0022 notes the difference.

The ReactiveUI.Binding replacements need a ReactiveUI release built on ReactiveUI.Binding. ReactiveUI 24.3 and earlier
are not: there, ReactiveUI's own `ObservableAsPropertyHelper<T>` and `IViewFor<T>` are the ones in use, so
Expand Down
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.3" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
<PackageVersion Include="ReactiveUI.Binding" Version="8.4.0" />
<PackageVersion Include="ReactiveUI.Binding.Reactive" Version="8.4.0" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.22" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.12" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" VersionOverride="4.14.0" />
<PackageReference Include="DynamicData" PrivateAssets="all" />
<PackageReference Include="ReactiveUI" />
<!-- The hosts' ObservedProperty, in both flavours; Binding's own generators are not run on the tests. -->
<PackageReference Include="ReactiveUI.Binding" ExcludeAssets="analyzers;build;buildTransitive" PrivateAssets="all" />
<PackageReference Include="ReactiveUI.Binding.Reactive" ExcludeAssets="analyzers;build;buildTransitive" PrivateAssets="all" />
<PackageReference Include="ReactiveUI.Reactive" PrivateAssets="all" />
<PackageReference Include="Splat" />
<PackageReference Include="System.Reactive" PrivateAssets="all" />
</ItemGroup>

<!-- The tests reference ReactiveUI.Binding for its runtime types only; its generator and analyzer still reach the
compiler, so they are removed before it runs. -->
<Target Name="RemoveReactiveUIBindingAnalyzers" BeforeTargets="CoreCompile">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'ReactiveUI.Binding.SourceGenerators' or '%(Filename)' == 'ReactiveUI.Binding.Analyzer'" />
</ItemGroup>
</Target>

<ItemGroup>
<Using Include="TUnit.Assertions" />
<Using Include="TUnit.Core" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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 ReactiveUI.SourceGenerators.WinForms;

namespace ReactiveUI.SourceGenerator.Tests;

/// <summary>Unit tests for <see cref="ControlHostAnalyzer" />.</summary>
public sealed class ControlHostAnalyzerTests
{
/// <summary>A WinForms host generated without ObservedProperty.</summary>
private const string WithoutObservedPropertyId = "RXUISG0022";

/// <summary>Both hosts, and a class that is neither.</summary>
private const string HostsSource = """
using ReactiveUI.SourceGenerators.WinForms;

namespace Hosts
{
[RoutedControlHost("System.Windows.Forms.UserControl")]
public partial class RoutedHost { }

[ViewModelControlHost("System.Windows.Forms.UserControl")]
public partial class ViewModelHost { }

[System.Obsolete]
public class NotAHost { }
}
""";

/// <summary>ReactiveUI.Binding without ObservedProperty: a release before 8.4.0.</summary>
private const string BindingWithoutObservedProperty = """
namespace ReactiveUI.Binding
{
public interface IViewFor<T> { }
}
""";

/// <summary>ReactiveUI.Binding 8.4.0 or later, with ObservedProperty.</summary>
private const string BindingWithObservedProperty = """
namespace ReactiveUI.Binding
{
public interface IViewFor<T> { }

public static class ObservedProperty { }
}
""";

/// <summary>Each host is reported when the compilation has no ReactiveUI.Binding with ObservedProperty.</summary>
/// <param name="bindingSource">Source standing in for ReactiveUI.Binding, or none.</param>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
[Arguments("")]
[Arguments(BindingWithoutObservedProperty)]
public async Task ReportsHostsWithoutObservedProperty(string bindingSource)
{
var reported = await GetReported(bindingSource);

await Assert.That(reported).IsEquivalentTo(["RoutedHost", "ViewModelHost"]);
}

/// <summary>No host is reported when ReactiveUI.Binding has ObservedProperty.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task DoesNotReportHostsWithObservedProperty() =>
await Assert.That(await GetReported(BindingWithObservedProperty)).IsEmpty();

/// <summary>The diagnostic names the ReactiveUI.Binding version that has ObservedProperty.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task NamesTheMinimumBindingVersion()
{
var descriptor = new ControlHostAnalyzer().SupportedDiagnostics[0];
var message = Diagnostic.Create(descriptor, Location.None, "RoutedHost", "8.4.0").GetMessage(System.Globalization.CultureInfo.InvariantCulture);

await Assert.That(descriptor.Id).IsEqualTo(WithoutObservedPropertyId);
await Assert.That(descriptor.DefaultSeverity).IsEqualTo(DiagnosticSeverity.Info);
await Assert.That(message).Contains("ReactiveUI.Binding 8.4.0 or later");
}

/// <summary>A null analysis context is rejected.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task InitializeRejectsANullContext() =>
await Assert.That(static () => new ControlHostAnalyzer().Initialize(null!)).Throws<ArgumentNullException>();

/// <summary>Analyzes the hosts with a ReactiveUI.Binding stand-in and gets the text each host is reported at.</summary>
/// <param name="bindingSource">Source standing in for ReactiveUI.Binding, or none.</param>
/// <returns>The source text at each reported location.</returns>
private static async Task<List<string>> GetReported(string bindingSource)
{
var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13);
var tree = CSharpSyntaxTree.ParseText(HostsSource, parseOptions);
var compilation = CSharpCompilation.Create(
nameof(ControlHostAnalyzerTests),
[tree, CSharpSyntaxTree.ParseText(bindingSource, parseOptions)],
TestCompilationReferences.CreateForAssemblies(typeof(object).Assembly, typeof(RoutedControlHostAttribute).Assembly),
new(OutputKind.DynamicallyLinkedLibrary));
var diagnostics = await compilation.WithAnalyzers([new ControlHostAnalyzer()]).GetAnalyzerDiagnosticsAsync();
var text = await tree.GetTextAsync();

var reported = new List<string>();
foreach (var diagnostic in diagnostics)
{
if (diagnostic.Id == WithoutObservedPropertyId)
{
reported.Add(text.ToString(diagnostic.Location.SourceSpan));
}
}

return reported;
}
}
Loading
Loading