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
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ private static void EmitGroup(
ImmutableArray<ClassBindingInfo> allClasses,
in LanguageFeatures features)
{
var collapsed = features.SupportsCallerArgExpr
var collapsed = features.CollapsesIndistinguishableCallSites
? group with
{
Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ private static void EmitGroup(
ImmutableArray<ClassBindingInfo> allClasses,
in LanguageFeatures features)
{
var collapsed = features.SupportsCallerArgExpr
var collapsed = features.CollapsesIndistinguishableCallSites
? group with
{
Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal static class BindToCodeGenerator

for (var g = 0; g < groups.Count; g++)
{
var group = supportsCallerArgExpr
var group = features.CollapsesIndistinguishableCallSites
? groups[g] with
{
Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal static class BindingEmitterHelpers

for (var g = 0; g < groups.Count; g++)
{
var group = snapshot.SupportsCallerArgExpr
var group = snapshot.CollapsesIndistinguishableCallSites
? groups[g] with
{
Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,23 @@ internal static bool HasItems<T>(ImmutableArray<T> items) =>
/// <param name="type">The type symbol, which may be null.</param>
/// <returns>The fully qualified type name, or <see langword="null"/> when no overload could name it.</returns>
/// <remarks>
/// <para>
/// A call made through a type parameter binds to whatever closes it, which the call site does not name.
/// Writing the parameter's own name into an overload puts an identifier no consumer declared into their
/// build, so the whole compilation fails over generated code they cannot edit - including every unrelated
/// call site in the project. Declining the call site leaves it on the runtime stub instead.
/// </para>
/// <para>
/// A static type fails the same way and reaches here by a different route: a call written through the
/// stub's declaring class - <c>ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name)</c> - puts that
/// class where the observed object goes, and no member can declare a parameter of it or close a generic
/// over it. Such a call resolves against that class's own members, so neither a generated overload nor an
/// interceptor matching the call written on an instance is a candidate for it either way.
/// </para>
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static string? GetDeclarableTypeDisplayName(ITypeSymbol? type) =>
type is INamedTypeSymbol named ? GetTypeDisplayName(named) : null;
type is INamedTypeSymbol { IsStatic: false } named ? GetTypeDisplayName(named) : null;

/// <summary>
/// Searches method parameters for a selector or conversion function parameter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,16 @@ internal readonly record struct LanguageFeatures(
bool UsesReactiveRuntime = false,
EquatableArray<string> RuntimeNamespaceMembers = default,
EquatableArray<string> PrimitivesNamespaceMembers = default,
bool SupportsInterceptors = false);
bool SupportsInterceptors = false)
{
/// <summary>Gets a value indicating whether call sites a dispatch cannot tell apart collapse to one.</summary>
/// <remarks>
/// Binding the same pair of properties from more than one place is ordinary, and expression-text dispatch
/// keys on the selectors as written: the first matching branch wins, so the later ones are unreachable and
/// only drag a binding method along. An interceptor instead names the call site it replaces, so dropping one
/// leaves it carrying no attribute - on the runtime engine while the call site beside it is generated. The
/// rule lives here because each API would otherwise decide it separately, and the one that forgot would
/// silently lose a binding rather than fail to compile.
/// </remarks>
internal bool CollapsesIndistinguishableCallSites => SupportsCallerArgExpr && !SupportsInterceptors;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
// 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.CSharp;
using ReactiveUI.Binding.SourceGenerators.Tests.Helpers;

namespace ReactiveUI.Binding.SourceGenerators.Tests;

/// <summary>
/// Tests the binding calls a consumer writes through the stub's declaring class -
/// <c>ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name)</c> rather than <c>vm.WhenChanged(x => x.Name)</c>.
/// </summary>
/// <remarks>
/// Every extractor reads the bound object from the receiver and the lambdas from the arguments after it, which
/// a call naming the class shifts by one: the receiver is the class itself and each argument sits one place
/// along. Generating from that produces a member declaring a parameter of a static type, which does not
/// compile - so the consumer's whole build fails over generated code they cannot edit, including every
/// unrelated call site in the project. Nothing could serve these call sites anyway: a call that names the
/// class resolves against that class's members, so neither a generated overload nor an interceptor matching
/// the reduced form is a candidate. They belong on the runtime stub.
/// </remarks>
public class DeclaringClassInvocationTests
{
/// <summary>The <c>WhenChangedDispatch.g.cs</c> name these tests generate against.</summary>
private const string WhenChangedDispatchFileName = "WhenChangedDispatch.g.cs";

/// <summary>The <c>WhenAnyObservableDispatch.g.cs</c> name these tests generate against.</summary>
private const string WhenAnyObservableDispatchFileName = "WhenAnyObservableDispatch.g.cs";

/// <summary>The <c>BindOneWayDispatch.g.cs</c> name these tests generate against.</summary>
private const string BindOneWayDispatchFileName = "BindOneWayDispatch.g.cs";

/// <summary>The <c>BindToDispatch.g.cs</c> name these tests generate against.</summary>
private const string BindToDispatchFileName = "BindToDispatch.g.cs";

/// <summary>The <c>BindCommandDispatch.g.cs</c> name these tests generate against.</summary>
private const string BindCommandDispatchFileName = "BindCommandDispatch.g.cs";

/// <summary>The <c>BindInteractionDispatch.g.cs</c> name these tests generate against.</summary>
private const string BindInteractionDispatchFileName = "BindInteractionDispatch.g.cs";

/// <summary>A <c>WhenChanged</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task WhenChanged_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public string Name { get; set; } = "";
}

public static class Scenario
{
public static IObservable<string> Execute(MyViewModel vm)
{
return ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name);
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(WhenChangedDispatchFileName);
}

/// <summary>A <c>WhenAnyObservable</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task WhenAnyObservable_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public IObservable<int>? Signal { get; set; }
}

public static class Scenario
{
public static IObservable<int> Execute(MyViewModel vm)
{
return ReactiveUIBindingExtensions.WhenAnyObservable(vm, x => x.Signal);
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(WhenAnyObservableDispatchFileName);
}

/// <summary>A <c>BindOneWay</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindOneWay_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public string Name { get; set; } = "";
}

public class MyView : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public string DisplayName { get; set; } = "";
}

public static class Scenario
{
public static IDisposable Execute(MyViewModel vm, MyView view)
{
return ReactiveUIBindingExtensions.BindOneWay(vm, view, x => x.Name, x => x.DisplayName);
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(BindOneWayDispatchFileName);
}

/// <summary>A <c>BindTo</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindTo_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyView : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public string DisplayName { get; set; } = "";
}

public static class Scenario
{
public static IDisposable Execute(IObservable<string> values, MyView view)
{
return ReactiveUIBindingExtensions.BindTo(values, view, x => x.DisplayName);
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(BindToDispatchFileName);
}

/// <summary>A <c>BindCommand</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindCommand_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using System.Windows.Input;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyButton
{
public event EventHandler? Click;
}

public class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public ICommand? Save { get; set; }
}

public class MyView : IViewFor
{
public object? ViewModel { get; set; }

public MyButton SaveButton { get; set; } = new MyButton();
}

public static class Scenario
{
public static IDisposable Execute(MyViewModel vm, MyView view)
{
return ReactiveUIBindingExtensions.BindCommand(view, vm, x => x.Save, x => x.SaveButton);
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(BindCommandDispatchFileName);
}

/// <summary>A <c>BindInteraction</c> call named through its declaring class emits no dispatch.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindInteraction_NamedThroughItsDeclaringClass_GeneratesNoDispatch()
{
const string source = """
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using ReactiveUI.Binding;

namespace TestApp
{
public class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

public Interaction<string, bool> Confirm { get; set; } = new Interaction<string, bool>();
}

public class MyView : IViewFor
{
public object? ViewModel { get; set; }
}

public static class Scenario
{
public static IDisposable Execute(MyViewModel vm, MyView view)
{
return ReactiveUIBindingExtensions.BindInteraction(view, vm, x => x.Confirm, Handle);
}

private static Task Handle(IInteractionContext<string, bool> context)
{
context.SetOutput(true);
return Task.CompletedTask;
}
}
}
""";

var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10);

await result.HasNoGeneratorDiagnostics();
await result.CompilationSucceeds();
await result.DoesNotHaveGeneratedSource(BindInteractionDispatchFileName);
}
}
Loading
Loading