diff --git a/CLAUDE.md b/CLAUDE.md index 3811a22b..9272a7ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 | diff --git a/README.md b/README.md index acdef769..687c84ed 100644 --- a/README.md +++ b/README.md @@ -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` and `IViewFor` are the ones in use, so diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7226d700..0a852bd5 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -24,6 +24,8 @@ + + diff --git a/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj b/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj index a81664e3..eaad9bd3 100644 --- a/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj +++ b/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj @@ -31,11 +31,22 @@ + + + + + + + + + + diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs new file mode 100644 index 00000000..7acda80f --- /dev/null +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs @@ -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; + +/// Unit tests for . +public sealed class ControlHostAnalyzerTests +{ + /// A WinForms host generated without ObservedProperty. + private const string WithoutObservedPropertyId = "RXUISG0022"; + + /// Both hosts, and a class that is neither. + 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 { } + } + """; + + /// ReactiveUI.Binding without ObservedProperty: a release before 8.4.0. + private const string BindingWithoutObservedProperty = """ + namespace ReactiveUI.Binding + { + public interface IViewFor { } + } + """; + + /// ReactiveUI.Binding 8.4.0 or later, with ObservedProperty. + private const string BindingWithObservedProperty = """ + namespace ReactiveUI.Binding + { + public interface IViewFor { } + + public static class ObservedProperty { } + } + """; + + /// Each host is reported when the compilation has no ReactiveUI.Binding with ObservedProperty. + /// Source standing in for ReactiveUI.Binding, or none. + /// A task that represents the asynchronous test operation. + [Test] + [Arguments("")] + [Arguments(BindingWithoutObservedProperty)] + public async Task ReportsHostsWithoutObservedProperty(string bindingSource) + { + var reported = await GetReported(bindingSource); + + await Assert.That(reported).IsEquivalentTo(["RoutedHost", "ViewModelHost"]); + } + + /// No host is reported when ReactiveUI.Binding has ObservedProperty. + /// A task that represents the asynchronous test operation. + [Test] + public async Task DoesNotReportHostsWithObservedProperty() => + await Assert.That(await GetReported(BindingWithObservedProperty)).IsEmpty(); + + /// The diagnostic names the ReactiveUI.Binding version that has ObservedProperty. + /// A task that represents the asynchronous test operation. + [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"); + } + + /// A null analysis context is rejected. + /// A task that represents the asynchronous test operation. + [Test] + public async Task InitializeRejectsANullContext() => + await Assert.That(static () => new ControlHostAnalyzer().Initialize(null!)).Throws(); + + /// Analyzes the hosts with a ReactiveUI.Binding stand-in and gets the text each host is reported at. + /// Source standing in for ReactiveUI.Binding, or none. + /// The source text at each reported location. + private static async Task> 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(); + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Id == WithoutObservedPropertyId) + { + reported.Add(text.ToString(diagnostic.Location.SourceSpan)); + } + } + + return reported; + } +} diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs new file mode 100644 index 00000000..04c6cd0e --- /dev/null +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs @@ -0,0 +1,366 @@ +// 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; +using System.Reflection; +using System.Runtime.Loader; +using ReactiveUI.SourceGenerators.WinForms; + +namespace ReactiveUI.SourceGenerator.Tests; + +/// +/// Runs the generated Windows Forms hosts against ReactiveUI.Binding 8.4.0 in each flavour: the hosts follow their +/// properties through Binding's ObservedProperty, show their default content, and swap in the view for a routed or +/// hosted view model. +/// +/// +/// A ReactiveUI built on ReactiveUI.Binding declares no IViewFor of its own; no such release is published yet, so +/// the few ReactiveUI types a host touches are declared in source, and Binding is the real package. +/// +public class ControlHostObservedPropertyTests +{ + /// The ReactiveUI types a host touches, as a ReactiveUI built on ReactiveUI.Binding declares them. + private const string ReactiveUISource = """ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Runtime.CompilerServices; + + namespace ReactiveUI + { + public interface IReactiveObject : INotifyPropertyChanged, INotifyPropertyChanging + { + void RaisePropertyChanging(PropertyChangingEventArgs args); + + void RaisePropertyChanged(PropertyChangedEventArgs args); + } + + public static class IReactiveObjectExtensions + { + public static TRet RaiseAndSetIfChanged(this TObj source, ref TRet field, TRet value, [CallerMemberName] string? name = null) + where TObj : IReactiveObject + { + if (EqualityComparer.Default.Equals(field, value)) + { + return value; + } + + source.RaisePropertyChanging(new(name)); + field = value; + source.RaisePropertyChanged(new(name)); + return value; + } + + public static void SubscribePropertyChangedEvents(this TObj source) where TObj : IReactiveObject { } + + public static void SubscribePropertyChangingEvents(this TObj source) where TObj : IReactiveObject { } + } + + public interface IScreen + { + RoutingState Router { get; } + } + + public interface IRoutableViewModel + { + string? UrlPathSegment { get; } + + IScreen HostScreen { get; } + } + + public sealed class RoutingState + { + private readonly List> _observers = new(); + + public IObservable CurrentViewModel => new Current(this); + + public void Navigate(IRoutableViewModel viewModel) + { + foreach (var observer in _observers.ToArray()) + { + observer.OnNext(viewModel); + } + } + + private sealed class Current(RoutingState state) : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + state._observers.Add(observer); + return new Unsubscribe(() => state._observers.Remove(observer)); + } + } + + private sealed class Unsubscribe(Func remove) : IDisposable + { + public void Dispose() => remove(); + } + } + + public static class RxApp + { + public static IObserver DefaultExceptionHandler { get; } = new Thrower(); + + private sealed class Thrower : IObserver + { + public void OnCompleted() { } + + public void OnError(Exception error) => throw error; + + public void OnNext(Exception value) => throw value; + } + } + } + """; + + /// The hosts, a view and a view locator; BINDING_NAMESPACE is the ReactiveUI.Binding flavour. + private const string HostsSource = """ + using System.ComponentModel; + using System.Windows.Forms; + using ReactiveUI.SourceGenerators.WinForms; + + namespace Hosts + { + [RoutedControlHost("System.Windows.Forms.UserControl")] + public partial class RoutedHost + { + private IContainer? components; + + private void InitializeComponent() { } + } + + [ViewModelControlHost("System.Windows.Forms.UserControl")] + public partial class ViewModelHost + { + private IContainer? components; + + private void InitializeComponent() { } + } + + public sealed class Shell : ReactiveUI.IScreen + { + public ReactiveUI.RoutingState Router { get; } = new(); + } + + public sealed class PageViewModel : ReactiveUI.IRoutableViewModel + { + public string? UrlPathSegment => "page"; + + public ReactiveUI.IScreen HostScreen { get; } = new Shell(); + } + + public sealed class PageView : UserControl, BINDING_NAMESPACE.IViewFor + { + public PageViewModel? ViewModel { get; set; } + + object? BINDING_NAMESPACE.IViewFor.ViewModel { get => ViewModel; set => ViewModel = (PageViewModel?)value; } + } + + public sealed class Locator : BINDING_NAMESPACE.IViewLocator + { + public BINDING_NAMESPACE.IViewFor? ResolveView(TViewModel viewModel, string? contract) + where TViewModel : class => ResolveView((object?)viewModel, contract); + + public BINDING_NAMESPACE.IViewFor? ResolveView(object? viewModel, string? contract) => + viewModel is PageViewModel ? new PageView() : null; + + public BINDING_NAMESPACE.IViewFor? ResolveViewUnsafe(object? viewModel, string? contract) => ResolveView(viewModel, contract); + } + } + """; + + /// The lean ReactiveUI.Binding namespace. + private const string Binding = "ReactiveUI.Binding"; + + /// The System.Reactive ReactiveUI.Binding namespace. + private const string BindingReactive = "ReactiveUI.Binding.Reactive"; + + /// The hosts' default content property. + private const string DefaultContent = nameof(DefaultContent); + + /// The view-model host's view model property, and a view's. + private const string ViewModel = nameof(ViewModel); + + /// Both hosts follow their properties through ObservedProperty, and no longer write their own observable. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task HostsFollowTheirPropertiesThroughObservedProperty(string bindingNamespace) + { + var (_, generated) = Generate(bindingNamespace); + + foreach (var source in generated) + { + await Assert.That(source).Contains($"global::{bindingNamespace}.ObservedProperty.Create(this, static x => x.DefaultContent, static x => x.DefaultContent)"); + await Assert.That(source).Contains($"global::{bindingNamespace}.ObservedProperty.Switch(global::{bindingNamespace}.ObservedProperty.Create(this, static x => x.ViewContractObservable"); + await Assert.That(source).DoesNotContain("PropertyObservable<"); + await Assert.That(source).DoesNotContain("WhenAny"); + } + } + + /// The routed host shows its default content, then the view for the view model the router navigates to. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task RoutedHostShowsDefaultContentThenTheRoutedView(string bindingNamespace) + { + var assembly = Compile(bindingNamespace); + var host = Create(assembly, "Hosts.RoutedHost"); + var defaultContent = Create(assembly, "System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create(assembly, "Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShows(host, defaultContent); + + var router = Create(assembly, "ReactiveUI.RoutingState"); + Set(host, "Router", router); + _ = router.GetType().GetMethod("Navigate")!.Invoke(router, [Create(assembly, "Hosts.PageViewModel")]); + + await AssertShowsPageView(host); + } + + /// The view-model host shows its default content, then the view for the view model it is given. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task ViewModelHostShowsDefaultContentThenTheViewForItsViewModel(string bindingNamespace) + { + var assembly = Compile(bindingNamespace); + var host = Create(assembly, "Hosts.ViewModelHost"); + var defaultContent = Create(assembly, "System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create(assembly, "Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShows(host, defaultContent); + + var viewModel = Create(assembly, "Hosts.PageViewModel"); + Set(host, ViewModel, viewModel); + + await AssertShowsPageView(host); + await Assert.That(Get(ShownControls(host)[0]!, ViewModel)).IsSameReferenceAs(viewModel); + } + + /// Gets the source of the hosts, a view and view locator, for a flavour. + /// The ReactiveUI.Binding flavour. + /// The source. + private static string GetHostsSource(string bindingNamespace) => + HostsSource.Replace("BINDING_NAMESPACE", bindingNamespace, StringComparison.Ordinal); + + /// Runs both host generators over a flavour's source, the desktop stubs and ReactiveUI.Binding. + /// The ReactiveUI.Binding flavour. + /// The output compilation and the generated host sources. + private static (Compilation Output, List GeneratedSources) Generate(string bindingNamespace) + { + var bindingAssembly = bindingNamespace == Binding + ? typeof(ReactiveUI.Binding.ObservedProperty).Assembly + : typeof(ReactiveUI.Binding.Reactive.ObservedProperty).Assembly; + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13); + var compilation = CSharpCompilation.Create( + $"ControlHostObservedProperty.{bindingNamespace}", + [ + CSharpSyntaxTree.ParseText(ReactiveUISource, parseOptions), + CSharpSyntaxTree.ParseText(GetHostsSource(bindingNamespace), parseOptions), + CSharpSyntaxTree.ParseText(TestCompilationReferences.WindowsDesktopStubs, parseOptions), + ], + TestCompilationReferences.CreateForAssemblies( + typeof(object).Assembly, + typeof(System.ComponentModel.Component).Assembly, + typeof(System.ComponentModel.INotifyPropertyChanged).Assembly, + typeof(System.Linq.Expressions.Expression).Assembly, + typeof(RoutedControlHostAttribute).Assembly, + bindingAssembly), + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var driver = CSharpGeneratorDriver + .Create([new RoutedControlHostGenerator().AsSourceGenerator(), new ViewModelControlHostGenerator().AsSourceGenerator()], parseOptions: parseOptions) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + + var generated = new List(); + foreach (var result in driver.GetRunResult().Results) + { + foreach (var file in result.GeneratedSources) + { + generated.Add(file.SourceText.ToString()); + } + } + + return (output, generated); + } + + /// Generates, compiles and loads a flavour's hosts. + /// The ReactiveUI.Binding flavour. + /// The loaded assembly. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "SES1402", Justification = "The test loads the assembly it has just compiled from its own source.")] + private static Assembly Compile(string bindingNamespace) + { + var (output, _) = Generate(bindingNamespace); + using var image = new MemoryStream(); + var emit = output.Emit(image); + if (!emit.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emit.Diagnostics)); + } + + image.Position = 0; + return new AssemblyLoadContext($"{nameof(ControlHostObservedPropertyTests)}.{bindingNamespace}", isCollectible: true).LoadFromStream(image); + } + + /// Creates an instance of a type from the compiled assembly. + /// The compiled assembly. + /// The type's full name. + /// The instance. + private static object Create(Assembly assembly, string typeName) => Activator.CreateInstance(assembly.GetType(typeName, throwOnError: true)!)!; + + /// Reads a public property. + /// The object. + /// The property name. + /// The property's value. + private static object Get(object target, string name) => target.GetType().GetProperty(name)!.GetValue(target)!; + + /// Sets a public property. + /// The object. + /// The property name. + /// The value. + private static void Set(object target, string name, object? value) => target.GetType().GetProperty(name)!.SetValue(target, value); + + /// Lists the controls a host shows. + /// The host. + /// The controls. + private static List ShownControls(object host) + { + var controls = new List(); + foreach (var control in (IEnumerable)Get(host, "Controls")) + { + controls.Add(control); + } + + return controls; + } + + /// Asserts that a host shows exactly one control, the given one. + /// The host. + /// The control it should show. + /// A task to monitor the async. + private static async Task AssertShows(object host, object control) + { + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]).IsSameReferenceAs(control); + } + + /// Asserts that a host shows exactly one control, a page view. + /// The host. + /// A task to monitor the async. + private static async Task AssertShowsPageView(object host) + { + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]!.GetType().FullName).IsEqualTo("Hosts.PageView"); + } +} diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md index f21a6dcb..ba84b6ab 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md @@ -18,6 +18,7 @@ Rule ID | Category | Severity | Notes RXUISG0002 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Error | A `[ReactiveCommand]` method takes more than one parameter besides a CancellationToken RXUISG0008 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Error | A `[ReactiveCommand]` method is async void RXUISG0021 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Warning | A `[ReactiveCommand]` OutputScheduler or BackgroundScheduler does not resolve to a scheduler +RXUISG0022 | ReactiveUI.SourceGenerators.CodeFixers.ControlHostAnalyzer | Info | A WinForms host follows its properties without ReactiveUI.Binding 8.4.0's ObservedProperty ## Rules diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs new file mode 100644 index 00000000..7adeaf18 --- /dev/null +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs @@ -0,0 +1,63 @@ +// 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; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ReactiveUI.SourceGenerators.Helpers; +using static ReactiveUI.SourceGenerators.CodeFixers.Diagnostics.DiagnosticDescriptors; + +namespace ReactiveUI.SourceGenerators.CodeFixers; + +/// Reports WinForms hosts generated without ReactiveUI.Binding's ObservedProperty. +/// The rule is the host generators' own, from the both compile. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ControlHostAnalyzer : DiagnosticAnalyzer +{ + /// Gets the diagnostics this analyzer reports. + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(ControlHostWithoutObservedPropertyRule); + + /// Registers the host analysis for compilations without ObservedProperty. + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(static startContext => + { + if (ViewApiRules.HasObservedProperty(startContext.Compilation)) + { + return; + } + + startContext.RegisterSymbolAction(static symbolContext => AnalyzeType(in symbolContext), SymbolKind.NamedType); + }); + } + + /// Reports a type that is a WinForms host. + /// The symbol analysis context. + private static void AnalyzeType(in SymbolAnalysisContext context) + { + foreach (var attribute in context.Symbol.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() is "ReactiveUI.SourceGenerators.WinForms.RoutedControlHostAttribute" + or "ReactiveUI.SourceGenerators.WinForms.ViewModelControlHostAttribute") + { + context.ReportDiagnostic(Diagnostic.Create( + ControlHostWithoutObservedPropertyRule, + context.Symbol.Locations[0], + context.Symbol.Name, + ViewApiRules.ObservedPropertyMinimumBindingVersion)); + return; + } + } + } +} diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs index 1b3c0d85..60918337 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs @@ -54,6 +54,17 @@ internal static class DiagnosticDescriptors + "such as RxSchedulers.MainThreadScheduler, whose type is the scheduler type ReactiveUI's commands take.", helpLinkUri: "https://www.reactiveui.net/docs/handbook/view-models/boilerplate-code.html"); + /// A WinForms host follows its properties without ReactiveUI.Binding's ObservedProperty. + internal static readonly DiagnosticDescriptor ControlHostWithoutObservedPropertyRule = new( + id: "RXUISG0022", + title: "WinForms host follows its properties without ObservedProperty", + messageFormat: "`{0}` follows its own properties through PropertyChanged; with ReactiveUI.Binding {1} or later it would follow them with WhenAnyValue semantics", + category: typeof(ControlHostAnalyzer).FullName, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The [RoutedControlHost] and [ViewModelControlHost] hosts use ReactiveUI.Binding's ObservedProperty when the referenced ReactiveUI.Binding has it.", + helpLinkUri: "https://www.reactiveui.net/docs/handbook/view-models/boilerplate-code.html"); + /// The `[Reactive]` attribute was used on a property, but required `partial` modifiers are missing. internal static readonly DiagnosticDescriptor ReactiveAttributeRequiresPartialRule = new( id: "RXUISG0020", diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj index 69f62a31..4447fb54 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj @@ -32,6 +32,8 @@ + + diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs similarity index 55% rename from src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs rename to src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs index b40106ec..060a7bfc 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs @@ -2,16 +2,64 @@ // 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.Models; + namespace ReactiveUI.SourceGenerators.CodeGeneration; /// The members both Windows Forms hosts share, written through a . /// -/// A host follows its own properties through its PropertyChanged event, never through WhenAny: the -/// binding engine's generator cannot see call sites in this generator's output, so a WhenAny in a host would not -/// be dispatched. +/// A host never calls WhenAny: the binding engine's generator cannot see call sites in this generator's output, +/// so a WhenAny in a host would not be dispatched. With ReactiveUI.Binding 8.4.0 or later a host follows its +/// properties through Binding's ObservedProperty, which has WhenAnyValue's semantics; with anything older +/// it follows them through its own PropertyObservable over its PropertyChanged event. /// -internal static class ControlHostWriter +internal static class ControlHostExtensions { + /// Writes the observables a host follows its own properties with. + /// The writer the observable expression is appended to. + extension(SourceWriter writer) + { + /// Writes an observable of a host property's value: its current value, then each change. + /// The detected ReactiveUI integration, which says whether ObservedProperty exists. + /// The property's type. + /// The property's name. + /// The writer. + internal SourceWriter AppendPropertyValue(ReactiveUiIntegration integration, string type, string property) => + integration.HasObservedProperty + ? writer.AppendObservedPropertyCreate(integration, property) + : writer.Append("new PropertyObservable<").Append(type).Append(">(this, nameof(").Append(property) + .Append("), () => new ReturnObservable<").Append(type).Append(">(").Append(property).Append("))"); + + /// Writes an observable of what the observable a host property holds produces, switching as it changes. + /// The ReactiveUI integration, which names the ObservedProperty flavour when there is one. + /// The type the property's observable produces. + /// The name of the property holding the observable. + /// The writer, after the expression. + internal SourceWriter AppendPropertyObservable(ReactiveUiIntegration integration, string type, string property) => + integration.HasObservedProperty + ? writer.Append(integration.ObservedProperty).Append(".Switch(").AppendObservedPropertyCreate(integration, property).Append(')') + : writer.Append("new PropertyObservable<").Append(type).Append(">(this, nameof(").Append(property) + .Append("), () => ").Append(property).Append(')'); + + /// Writes an observable of the routed host's current view model, following Router.CurrentViewModel. + /// The integration that decides between ObservedProperty and the host's own observable. + /// The writer, after the routed view model expression. + internal SourceWriter AppendRoutedViewModel(ReactiveUiIntegration integration) => + integration.HasObservedProperty + ? writer.Append(integration.ObservedProperty).Append(".Switch(") + .Append(integration.ObservedProperty).Append(".Then(").AppendObservedPropertyCreate(integration, "Router") + .Append(", static router => router.CurrentViewModel, static router => router.CurrentViewModel))") + : writer.Append("new PropertyObservable(this, nameof(Router), () => Router?.CurrentViewModel)"); + + /// Writes ObservedProperty.Create for a host property, with static lambdas that do not allocate. + /// The integration naming the flavour ObservedProperty is written from. + /// The observed property's name. + /// The writer, after the call. + private SourceWriter AppendObservedPropertyCreate(ReactiveUiIntegration integration, string property) => + writer.Append(integration.ObservedProperty).Append(".Create(this, static x => x.").Append(property) + .Append(", static x => x.").Append(property).Append(')'); + } + /// Writes the host's change events and its IReactiveObject implementation. /// The writer, at the level of the host's members. /// diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs index 0db54264..aa408cd6 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs @@ -22,23 +22,18 @@ internal static class ContextExtensions /// The metadata name of the ReactiveUI primitive void type. private const string RxVoidMetadataName = "ReactiveUI.Primitives.RxVoid"; - /// The metadata name of the IViewFor<T> declared by ReactiveUI releases not built on ReactiveUI.Binding. - private const string ReactiveUIViewForMetadataName = "ReactiveUI.IViewFor`1"; - - /// The metadata name of ReactiveUI.Binding's IViewFor<T>. - private const string BindingViewForMetadataName = "ReactiveUI.Binding.IViewFor`1"; - - /// The metadata name of ReactiveUI.Binding.Reactive's IViewFor<T>. - private const string BindingReactiveViewForMetadataName = "ReactiveUI.Binding.Reactive.IViewFor`1"; - /// Provides extension members for compilations. /// The compilation to extend. extension(Compilation compilation) { /// Gets the ReactiveUI integration supported by this compilation. - /// The ReactiveUI API, command behavior and view API supported by this compilation. + /// The ReactiveUI API, command behavior, view API and property observation supported by this compilation. internal ReactiveUiIntegration GetReactiveUiIntegration() => - GetApiIntegration(compilation) with { ViewApi = GetViewApi(compilation) }; + GetApiIntegration(compilation) with + { + ViewApi = GetViewApi(compilation), + HasObservedProperty = ViewApiRules.HasObservedProperty(compilation), + }; } /// Provides extension members for generator attribute syntax contexts. @@ -315,29 +310,14 @@ private static ReactiveUiIntegration GetApiIntegration(Compilation compilation) /// Gets which assembly declares the IViewFor interfaces and the view locator a compilation uses. /// The compilation to inspect. - /// The view API. - /// - /// A ReactiveUI that declares its own IViewFor<T> is not built on ReactiveUI.Binding, so its interface - /// wins even when ReactiveUI.Binding is referenced too. Otherwise the ReactiveUI.Binding flavour matching the - /// ReactiveUI flavour is used: the System.Reactive one when ReactiveUI.Reactive is referenced or it is the only - /// one present. - /// - private static ReactiveUiViewApi GetViewApi(Compilation compilation) - { - if (compilation.GetTypeByMetadataName(ReactiveUIViewForMetadataName) is not null) + /// The view API, chosen by . + private static ReactiveUiViewApi GetViewApi(Compilation compilation) => + ViewApiRules.GetViewNamespace(compilation) switch { - return ReactiveUiViewApi.ReactiveUI; - } - - var hasBinding = compilation.GetTypeByMetadataName(BindingViewForMetadataName) is not null; - if (compilation.GetTypeByMetadataName(BindingReactiveViewForMetadataName) is not null - && (!hasBinding || compilation.GetTypeByMetadataName("ReactiveUI.Reactive.ReactiveCommand") is not null)) - { - return ReactiveUiViewApi.BindingReactive; - } - - return hasBinding ? ReactiveUiViewApi.Binding : ReactiveUiViewApi.ReactiveUI; - } + ViewApiRules.BindingNamespace => ReactiveUiViewApi.Binding, + ViewApiRules.BindingReactiveNamespace => ReactiveUiViewApi.BindingReactive, + _ => ReactiveUiViewApi.ReactiveUI, + }; /// Gets the integration details from a referenced ReactiveUI assembly. /// The compilation to inspect. diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs new file mode 100644 index 00000000..8c5bce6d --- /dev/null +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs @@ -0,0 +1,75 @@ +// 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 Microsoft.CodeAnalysis; + +namespace ReactiveUI.SourceGenerators.Helpers; + +/// The rules that pick the assembly whose view API, and property observation, generated hosts use. +/// +/// The generators and the code-fix analyzers both compile this file, so a host the generator writes without +/// ObservedProperty is exactly the host the analyzer reports. +/// +internal static class ViewApiRules +{ + /// The namespace of ReactiveUI's own view API, on releases not built on ReactiveUI.Binding. + internal const string ReactiveUINamespace = "ReactiveUI"; + + /// The namespace of the lean ReactiveUI.Binding. + internal const string BindingNamespace = "ReactiveUI.Binding"; + + /// The namespace of the System.Reactive ReactiveUI.Binding. + internal const string BindingReactiveNamespace = "ReactiveUI.Binding.Reactive"; + + /// The first ReactiveUI.Binding release with ObservedProperty. + internal const string ObservedPropertyMinimumBindingVersion = "8.4.0"; + + /// The metadata name of ReactiveUI's own IViewFor<T>. + private const string ReactiveUIViewForMetadataName = $"{ReactiveUINamespace}.IViewFor`1"; + + /// The metadata name of the lean ReactiveUI.Binding's IViewFor<T>. + private const string BindingViewForMetadataName = $"{BindingNamespace}.IViewFor`1"; + + /// The metadata name of the System.Reactive ReactiveUI.Binding's IViewFor<T>. + private const string BindingReactiveViewForMetadataName = $"{BindingReactiveNamespace}.IViewFor`1"; + + /// Gets the namespace declaring the IViewFor interfaces and the view locator a compilation uses. + /// The compilation. + /// The namespace, without the global:: alias. + /// + /// A ReactiveUI that declares its own IViewFor<T> is not built on ReactiveUI.Binding, so its interface + /// wins even when ReactiveUI.Binding is referenced too. Otherwise the ReactiveUI.Binding flavour matching the + /// ReactiveUI flavour is used: the System.Reactive one when ReactiveUI.Reactive is referenced or it is the only + /// one present. + /// + internal static string GetViewNamespace(Compilation compilation) + { + if (compilation.GetTypeByMetadataName(ReactiveUIViewForMetadataName) is not null) + { + return ReactiveUINamespace; + } + + var hasBinding = compilation.GetTypeByMetadataName(BindingViewForMetadataName) is not null; + if (compilation.GetTypeByMetadataName(BindingReactiveViewForMetadataName) is not null + && (!hasBinding || compilation.GetTypeByMetadataName("ReactiveUI.Reactive.ReactiveCommand") is not null)) + { + return BindingReactiveNamespace; + } + + return hasBinding ? BindingNamespace : ReactiveUINamespace; + } + + /// Determines whether the view API a compilation uses has ReactiveUI.Binding's ObservedProperty. + /// The compilation. + /// + /// when the compilation uses a ReactiveUI.Binding flavour at version + /// or later. + /// + internal static bool HasObservedProperty(Compilation compilation) + { + var viewNamespace = GetViewNamespace(compilation); + return viewNamespace != ReactiveUINamespace + && compilation.GetTypeByMetadataName($"{viewNamespace}.ObservedProperty") is not null; + } +} diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs index cdbdb953..2e0762b2 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs @@ -8,8 +8,17 @@ namespace ReactiveUI.SourceGenerators.Models; /// The implementation API selected by the compilation. /// Whether the compilation references ReactiveUI 22 or later. /// The assembly declaring the IViewFor interfaces and the view locator. -internal readonly record struct ReactiveUiIntegration(ReactiveUiApi Api, bool IsNewerThan22, ReactiveUiViewApi ViewApi = ReactiveUiViewApi.ReactiveUI) +/// Whether the view API's assembly has ReactiveUI.Binding's ObservedProperty. +internal readonly record struct ReactiveUiIntegration( + ReactiveUiApi Api, + bool IsNewerThan22, + ReactiveUiViewApi ViewApi = ReactiveUiViewApi.ReactiveUI, + bool HasObservedProperty = false) { + /// Gets ReactiveUI.Binding's ObservedProperty in the view API's flavour. + /// Only meaningful when is set. + internal string ObservedProperty => $"{ViewNamespace}.ObservedProperty"; + /// Gets the namespace containing the selected ReactiveUI implementation types. internal string Namespace => Api == ReactiveUiApi.SystemReactive ? "global::ReactiveUI.Reactive" diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs index 80b79d0c..97eefd80 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs @@ -113,11 +113,15 @@ private static string GenerateSource(RoutedControlHostInfo info, ReactiveUiInteg .Append(" : ").Append(info.BaseTypeName).Line(", IReactiveObject") .OpenBlock(); - WriteConstructor(writer, info.TargetName, exceptionHandler); + WriteConstructor(writer, info.TargetName, exceptionHandler, integration); WriteProperties(writer.BlankLine(), integration); WriteDispose(writer.BlankLine()); WriteRouting(writer.BlankLine(), integration); - ControlHostWriter.WritePropertyObservable(writer.BlankLine()); + if (!integration.HasObservedProperty) + { + ControlHostExtensions.WritePropertyObservable(writer.BlankLine()); + } + WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -156,7 +160,8 @@ private static void WriteFileHeader(SourceWriter writer, ReactiveUiIntegration i /// The writer, at the level of the host's members. /// The host type's name. /// The ReactiveUI default exception handler subscriptions report to. - private static void WriteConstructor(SourceWriter writer, string typeName, string exceptionHandler) => + /// The detected ReactiveUI integration, which picks how the host follows its properties. + private static void WriteConstructor(SourceWriter writer, string typeName, string exceptionHandler, ReactiveUiIntegration integration) => _ = writer.Lines(""" private readonly DisposableCollection _disposables = new(); private RoutingState? _router; @@ -170,9 +175,9 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .Line("/// ") .Append("public ").Append(typeName).Line("()") .OpenBlock() + .Line("InitializeComponent();") + .Append("_disposables.Add(").AppendPropertyValue(integration, "Control?", "DefaultContent").Line(".Subscribe(new ValueObserver(x =>") .Lines(""" - InitializeComponent(); - _disposables.Add(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)).Subscribe(new ValueObserver(x => { if (x is not null && Controls.Count == 0) { @@ -190,9 +195,9 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .Lines(""" _disposables.Add(routeSubscription); routeSubscription.Connect( - new PropertyObservable(this, nameof(Router), () => Router?.CurrentViewModel), - new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) + .Indent().AppendRoutedViewModel(integration).Line(",") + .AppendPropertyObservable(integration, "string", "ViewContractObservable").Line(");").Outdent() .CloseBlock(); /// Writes the host's events and properties, and its IReactiveObject implementation. @@ -200,7 +205,7 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin /// The detected ReactiveUI integration, which names the view locator's interface. private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) { - ControlHostWriter.WritePropertyChangeEvents(writer); + ControlHostExtensions.WritePropertyChangeEvents(writer); _ = writer.BlankLine().Lines(""" /// /// Gets or sets the default content. diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs index 21331514..12bfdb10 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs @@ -111,9 +111,13 @@ private static string GenerateSource(ViewModelControlHostInfo info, ReactiveUiIn WriteProperties(writer.BlankLine(), integration); WriteBindableProperties(writer.BlankLine()); WriteDispose(writer.BlankLine()); - WriteSetupBindings(writer.BlankLine(), exceptionHandler); + WriteSetupBindings(writer.BlankLine(), exceptionHandler, integration); WriteUpdateContent(writer.BlankLine(), integration); - ControlHostWriter.WritePropertyObservable(writer.BlankLine()); + if (!integration.HasObservedProperty) + { + ControlHostExtensions.WritePropertyObservable(writer.BlankLine()); + } + WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -178,7 +182,7 @@ private static void WriteConstructor(SourceWriter writer, string typeName) => /// The detected ReactiveUI integration, which names the view locator's interface. private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) { - ControlHostWriter.WritePropertyChangeEvents(writer); + ControlHostExtensions.WritePropertyChangeEvents(writer); _ = writer.BlankLine().Lines(""" /// /// Gets or sets a value indicating whether [default cache views enabled]. @@ -265,11 +269,12 @@ protected override void Dispose(bool disposing) /// Writes the bindings that swap the hosted view as the content, default content, and view model change. /// The writer, at the level of the host's members. /// The ReactiveUI default exception handler subscriptions report to. - private static void WriteSetupBindings(SourceWriter writer, string exceptionHandler) => + /// The detected ReactiveUI integration, which picks how the host follows its properties. + private static void WriteSetupBindings(SourceWriter writer, string exceptionHandler, ReactiveUiIntegration integration) => _ = writer.Line("private void SetupBindings()") .OpenBlock() + .Append("AddSubscription(").AppendPropertyValue(integration, "object?", "Content").Line(", new ValueObserver(x =>") .Lines(""" - AddSubscription(new PropertyObservable(this, nameof(Content), () => new ReturnObservable(Content)), new ValueObserver(x => { if (x is not Control control) { @@ -290,8 +295,8 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand ResumeLayout(); """) .Append("}, ").Append(exceptionHandler).Line("));") + .Append("AddSubscription(").AppendPropertyValue(integration, "Control?", "DefaultContent").Line(", new ValueObserver(x =>") .Lines(""" - AddSubscription(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)), new ValueObserver(x => { if (x is not null) { @@ -308,9 +313,9 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand .Lines(""" _disposables.Add(viewModelSubscription); viewModelSubscription.Connect( - new PropertyObservable(this, nameof(ViewModel), () => new ReturnObservable(ViewModel)), - new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) + .Indent().AppendPropertyValue(integration, "object?", "ViewModel").Line(",") + .AppendPropertyObservable(integration, "string", "ViewContractObservable").Line(");").Outdent() .CloseBlock(); /// Writes the methods that register a subscription and resolve the view for a new view model.