diff --git a/src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md b/src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md
index cc113f89..ffed4bd6 100644
--- a/src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md
+++ b/src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md
@@ -2,24 +2,24 @@
Rule ID | Category | Severity | Notes
-------------|----------|----------|-----------------------------------------------------------------------------------------
- RXUIBIND001 | Usage | Info | Expression must be inline lambda for compile-time optimization
+ RXUIBIND001 | Usage | Error | Expression must be inline lambda for compile-time optimization
RXUIBIND002 | Usage | Warning | Type has no observable properties
- RXUIBIND003 | Usage | Warning | Expression contains private/protected member
+ RXUIBIND003 | Usage | Error | Expression contains private/protected member
RXUIBIND004 | Usage | Warning | Type does not support before-change notifications
RXUIBIND005 | Usage | Info | Source type implements INotifyDataErrorInfo; validation binding requires runtime engine
- RXUIBIND006 | Usage | Warning | Expression contains unsupported path segment (indexer, static field, read-only leaf field, or method call)
+ RXUIBIND006 | Usage | Error | Expression contains unsupported path segment (indexer, static field, read-only leaf field, or method call)
RXUIBIND007 | Usage | Warning | Control has no bindable event
RXUIBIND008 | Usage | Warning | Property is not an IInteraction
- RXUIBIND009 | Usage | Warning | Generated binding dispatch is out of reach for this file
+ RXUIBIND009 | Usage | Error | Generated binding dispatch is out of reach for this file
RXUIBIND010 | Usage | Warning | Observed path passes through a type that raises no notification
RXUIBIND011 | Usage | Warning | Binding call resolved to ReactiveUI's own mixin
- RXUIBIND012 | Usage | Warning | ToProperty source raises no notification generated code can reach
- RXUIBIND013 | Usage | Warning | ToProperty property must be named directly
+ RXUIBIND012 | Usage | Error | ToProperty source raises no notification generated code can reach
+ RXUIBIND013 | Usage | Error | ToProperty property must be named directly
RXUIBIND014 | Usage | Error | Name the ToProperty initial value below C# 13
- RXUIBIND015 | Usage | Warning | Binding call names a type generated code cannot reach
- RXUIBIND016 | Usage | Warning | Binding call is made through a type parameter
+ RXUIBIND015 | Usage | Error | Binding call names a type generated code cannot reach
+ RXUIBIND016 | Usage | Error | 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
RXUIBIND020 | Usage | Info | View is registered only in the service locator
- RXUIBIND021 | Usage | Warning | Binding call has no generated binding
+ RXUIBIND021 | Usage | Error | Binding call has no generated binding
diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs
index 926c70d7..79de1ac4 100644
--- a/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs
+++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs
@@ -7,6 +7,7 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Operations;
namespace ReactiveUI.Binding.Analyzer.Analyzers;
@@ -382,6 +383,25 @@ internal static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol
return false;
}
+ /// Determines whether a generated interceptor claims a binding call.
+ /// The call.
+ /// The cancellation token.
+ /// when an interceptor replaces the call.
+ internal static bool IsIntercepted(IInvocationOperation invocation, CancellationToken cancellationToken)
+ {
+#if ROSLYN_4_13
+ // A binding method is only ever called through an invocation expression, and an operation handed to an
+ // analyzer always carries the model it was bound with.
+ return invocation.SemanticModel!.GetInterceptorMethod((InvocationExpressionSyntax)invocation.Syntax, cancellationToken) is not null;
+#else
+
+ // The baseline compiler has no interceptors, so no call is ever claimed.
+ _ = invocation;
+ _ = cancellationToken;
+ return false;
+#endif
+ }
+
/// Checks if a method is an API or its Unsafe twin.
/// The name of the method.
/// The name of the API.
diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/BindingInvocationAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/BindingInvocationAnalyzer.cs
index 9b823288..2bb1c8fb 100644
--- a/src/ReactiveUI.Binding.Analyzer/Analyzers/BindingInvocationAnalyzer.cs
+++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/BindingInvocationAnalyzer.cs
@@ -64,7 +64,7 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
var methodSymbol = invocationOp.TargetMethod;
// ToProperty only reads a name from its selector, so the path checks do not apply; ToPropertyAnalyzer covers it.
- if (!AnalyzerHelpers.IsBindingExtensionMethod(methodSymbol)
+ if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
|| methodSymbol.Name.EndsWith(UnsafeMethodSuffix, StringComparison.Ordinal)
|| methodSymbol.Name == Constants.ToPropertyMethodName)
{
@@ -74,14 +74,14 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
var methodName = methodSymbol.Name;
var arguments = invocationOp.Arguments;
- // Check RXUIBIND001: Non-inline lambda
- CheckNonInlineLambda(context, arguments);
-
- // Check RXUIBIND003: Private/protected member access
- CheckPrivateMember(context, arguments);
-
- // Check RXUIBIND006: Unsupported path segments (indexer, field, method call)
- CheckUnsupportedPathSegment(context, arguments);
+ // RXUIBIND001, 003 and 006 name a path the generator cannot read, so the call throws. A call an interceptor
+ // claims was read after all, from the caller's own partial class, and has nothing to report.
+ if (!AnalyzerHelpers.IsIntercepted(invocationOp, context.CancellationToken))
+ {
+ CheckNonInlineLambda(context, arguments);
+ CheckPrivateMember(context, arguments);
+ CheckUnsupportedPathSegment(context, arguments);
+ }
// Check RXUIBIND010: A link in the middle of the path that raises no notification
CheckSilentPathLink(context, arguments);
diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/NoGeneratedBindingAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/NoGeneratedBindingAnalyzer.cs
index 3cbb58d4..9b98bd4d 100644
--- a/src/ReactiveUI.Binding.Analyzer/Analyzers/NoGeneratedBindingAnalyzer.cs
+++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/NoGeneratedBindingAnalyzer.cs
@@ -4,6 +4,7 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using ReactiveUI.Binding.Helpers;
@@ -23,6 +24,11 @@ namespace ReactiveUI.Binding.Analyzer.Analyzers;
/// adds, which the generator cannot see.
///
///
+/// The call would throw, so it is an error. The one exception is a member that only generated code declares: no generator
+/// sees another's output, and only that generator could change it, so that call stays a warning. A call another error
+/// already reports, because it names a type generated code cannot name, is not reported twice.
+///
+///
/// Interceptors exist only on Roslyn 4.13 and newer, so the analyzer built against an older Roslyn has no interceptor to
/// ask about. The generator that loads beside it cannot write one either, so the stub is always the whole answer there.
///
@@ -55,7 +61,8 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
if (!AnalyzerHelpers.GeneratedApiNames.Contains(method.Name)
|| !IsRuntimeStub(method.ContainingType)
|| !Throws(method)
- || IsIntercepted(invocation, context.CancellationToken))
+ || AnalyzerHelpers.IsIntercepted(invocation, context.CancellationToken)
+ || IsReportedAsUnreachable(invocation, in context))
{
return;
}
@@ -63,9 +70,66 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticWarnings.NoGeneratedBinding,
invocation.Syntax.GetLocation(),
+ NamesAnotherGeneratorsMember(invocation, context.CancellationToken) ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
+ null,
+ null,
method.Name));
}
+ /// Determines whether a call's lambdas name a member that only generated code declares.
+ /// The call.
+ /// The cancellation token.
+ /// when some member the call names is declared only in generated files.
+ ///
+ /// No generator sees another generator's output, so such a member is invisible to this one. The caller cannot change
+ /// that, so this one cause stays a warning. Every other cause is something the caller can fix, and fails the build.
+ ///
+ internal static bool NamesAnotherGeneratorsMember(IInvocationOperation invocation, CancellationToken cancellationToken)
+ {
+ var model = invocation.SemanticModel!;
+ foreach (var argument in invocation.Arguments)
+ {
+ if (argument.Value.Syntax is not LambdaExpressionSyntax { Body: ExpressionSyntax body })
+ {
+ continue;
+ }
+
+ for (var current = AnalyzerHelpers.SkipNullForgivingAndParentheses(body);
+ current is MemberAccessExpressionSyntax access;
+ current = AnalyzerHelpers.SkipNullForgivingAndParentheses(access.Expression))
+ {
+ if (model.GetSymbolInfo(access, cancellationToken).Symbol is { } member && IsDeclaredOnlyInGeneratedCode(member))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// Determines whether every declaration of a member sits in a generated file.
+ /// The member.
+ /// when the member has declarations and all of them are generated.
+ internal static bool IsDeclaredOnlyInGeneratedCode(ISymbol member)
+ {
+ var references = member.DeclaringSyntaxReferences;
+ if (references.IsEmpty)
+ {
+ return false;
+ }
+
+ foreach (var reference in references)
+ {
+ if (!IsGeneratedFile(reference.SyntaxTree))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/// Determines whether a method is declared by a runtime stub class, whose binding methods throw.
/// The type declaring the method.
/// for the stub classes, directly or through an extension block's grouping type.
@@ -108,25 +172,19 @@ private static bool Throws(IMethodSymbol method)
private static bool IsStubName(string name) =>
name is Constants.StubExtensionClassName or Constants.SchedulerExtensionClassName;
- /// Determines whether a generated interceptor claims the call.
+ /// Determines whether RXUIBIND015 or RXUIBIND016 already reports the call, which names a type generated code cannot name.
/// The call.
- /// The cancellation token.
- /// when an interceptor replaces the call.
- private static bool IsIntercepted(IInvocationOperation invocation, CancellationToken cancellationToken)
- {
-#if ROSLYN_4_13
- // A binding method is only ever called through an invocation expression, and an operation handed to an
- // analyzer always carries the model it was bound with.
- return Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetInterceptorMethod(
- invocation.SemanticModel!,
- (Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax)invocation.Syntax,
- cancellationToken) is not null;
-#else
-
- // The baseline compiler has no interceptors, so every call that resolves to the stub runs it.
- _ = invocation;
- _ = cancellationToken;
- return false;
-#endif
- }
+ /// The operation analysis context.
+ /// when the call names an anonymous, private, protected or type-parameter type.
+ /// That error already fails the build at the call, so a second report of the same call adds nothing.
+ private static bool IsReportedAsUnreachable(IInvocationOperation invocation, in OperationAnalysisContext context) =>
+ UnreachableTypeAnalyzer.FindUnreachable(invocation, in context) is not null;
+
+ /// Determines whether a file is generated, by its name or its auto-generated marker.
+ /// The file.
+ /// for a generated file.
+ private static bool IsGeneratedFile(SyntaxTree tree) =>
+ tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase)
+ || tree.FilePath.EndsWith(".generated.cs", StringComparison.OrdinalIgnoreCase)
+ || tree.GetRoot().GetLeadingTrivia().ToString().IndexOf("= 0;
}
diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/ToPropertyAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/ToPropertyAnalyzer.cs
index fa0702a9..a822a9ba 100644
--- a/src/ReactiveUI.Binding.Analyzer/Analyzers/ToPropertyAnalyzer.cs
+++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/ToPropertyAnalyzer.cs
@@ -55,7 +55,9 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
var method = invocation.TargetMethod;
// The name test comes first: it is one string comparison, and it turns away every other invocation.
- if (method.Name != Constants.ToPropertyMethodName || !AnalyzerHelpers.IsBindingExtensionMethod(method))
+ if (method.Name != Constants.ToPropertyMethodName
+ || !AnalyzerHelpers.IsBindingExtensionMethod(method)
+ || AnalyzerHelpers.IsIntercepted(invocation, context.CancellationToken))
{
return;
}
@@ -63,13 +65,13 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
var property = FindArgument(invocation.Arguments, PropertyParameterName);
var source = FindArgument(invocation.Arguments, SourceParameterName);
- if (property is not null && !NamesPropertyReadably(property))
+ if (!NamesPropertyReadably(property))
{
context.ReportDiagnostic(Diagnostic.Create(DiagnosticWarnings.UnreadableToPropertyName, property.Value.Syntax.GetLocation()));
}
// A source typed by a type parameter is not generated for either, and names no type the warning could fix.
- if (source?.Parameter?.Type is not INamedTypeSymbol sourceType
+ if (source.Parameter!.Type is not INamedTypeSymbol sourceType
|| PropertyRaisePluginRegistry.Select(sourceType, context.Compilation) is not null)
{
return;
@@ -85,7 +87,7 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
/// The property argument.
/// for x => x.Property or a constant, non-blank string.
internal static bool NamesPropertyReadably(IArgumentOperation property) =>
- property.Parameter?.Type.SpecialType == SpecialType.System_String
+ property.Parameter!.Type.SpecialType == SpecialType.System_String
? property.Value.ConstantValue is { HasValue: true, Value: string name } && !string.IsNullOrWhiteSpace(name)
: IsDirectMemberSelector(property.Value.Syntax);
@@ -114,17 +116,16 @@ internal static bool IsDirectMemberSelector(SyntaxNode syntax)
/// Finds the argument passed for a parameter, whatever position or name it was passed by.
/// The invocation's arguments.
/// The parameter name.
- /// The argument, or null when the overload has no such parameter.
- private static IArgumentOperation? FindArgument(ImmutableArray arguments, string parameterName)
+ /// The argument.
+ /// Every ToProperty overload takes both the source and the property, so the argument is always there.
+ private static IArgumentOperation FindArgument(ImmutableArray arguments, string parameterName)
{
- for (var i = 0; i < arguments.Length; i++)
+ var i = 0;
+ while (arguments[i].Parameter!.Name != parameterName)
{
- if (arguments[i].Parameter?.Name == parameterName)
- {
- return arguments[i];
- }
+ i++;
}
- return null;
+ return arguments[i];
}
}
diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/UnreachableTypeAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/UnreachableTypeAnalyzer.cs
index 4a07d347..a2cef4b9 100644
--- a/src/ReactiveUI.Binding.Analyzer/Analyzers/UnreachableTypeAnalyzer.cs
+++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/UnreachableTypeAnalyzer.cs
@@ -14,14 +14,20 @@
namespace ReactiveUI.Binding.Analyzer.Analyzers;
///
-/// Reports binding calls that name a type generated code cannot reach (RXUIBIND015): a private or protected nested
-/// type, or a generic closed over one, in the call's signature or along an observed path.
+/// Reports binding calls that name a type generated code cannot reach (RXUIBIND015): an anonymous type, a private or
+/// protected nested type, or a type built from one, in the call's signature or along an observed path.
///
///
-/// Generated overloads and interceptors are declared in a class of their own, so the generator declines such a call
-/// and it stays on the runtime stub, which throws when it runs. The checks mirror the generator's: the closed
-/// type arguments of the resolved method, then the owner of each link of every selector lambda. A link's value type
-/// is the next link's owner, or at the leaf one of the type arguments, so it needs no check of its own.
+///
+/// Generated overloads and interceptors are declared in a class of their own. The checks mirror the generator's: the
+/// closed type arguments of the resolved method, then the owner of each link of every selector lambda. A link's value
+/// type is the next link's owner, or at the leaf one of the type arguments, so it needs no check of its own.
+///
+///
+/// On Roslyn 4.13 and newer the generator claims some of these calls with a generic interceptor. A claimed call is
+/// not reported. Any other such call would reach the runtime stub and throw, so RXUIBIND015 is an error that fails
+/// the build at the call.
+///
///
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class UnreachableTypeAnalyzer : DiagnosticAnalyzer
@@ -49,14 +55,15 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
var invocation = (IInvocationOperation)context.Operation;
var method = invocation.TargetMethod;
- if (!AnalyzerHelpers.IsBindingExtensionMethod(method) || AnalyzerHelpers.IsUnsafeBindingMethod(method) || !IsGeneratedMethodName(method.Name))
+ if (!SourceGenerators.Helpers.ExtractorValidation.IsRecognizedExtensionClass(method.ContainingType)
+ || AnalyzerHelpers.IsUnsafeBindingMethod(method)
+ || !IsGeneratedMethodName(method.Name))
{
return;
}
- var unreachable = FindUnreachableInSignature(method, context.Compilation)
- ?? FindUnreachableInPaths(invocation.Arguments, context);
- if (unreachable is null)
+ var unreachable = FindUnreachable(invocation, in context);
+ if (unreachable is null || AnalyzerHelpers.IsIntercepted(invocation, context.CancellationToken))
{
return;
}
@@ -69,23 +76,25 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
unreachable.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
}
+ /// Finds the first type a binding call names that generated code cannot name.
+ /// The binding call.
+ /// The operation analysis context.
+ /// The unreachable type, or null when every type is reachable.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ITypeSymbol? FindUnreachable(IInvocationOperation invocation, in OperationAnalysisContext context) =>
+ FindUnreachableInSignature(invocation.TargetMethod, context.Compilation)
+ ?? FindUnreachableInPaths(invocation.Arguments, context);
+
/// Finds the first type argument of a resolved method that generated code cannot name.
/// The resolved binding method.
/// The consumer compilation.
/// The unreachable type, or null when every type is reachable.
- internal static ITypeSymbol? FindUnreachableInSignature(IMethodSymbol method, Compilation compilation)
- {
- var typeArguments = method.TypeArguments;
- for (var i = 0; i < typeArguments.Length; i++)
- {
- if (!IsReachable(typeArguments[i], compilation))
- {
- return typeArguments[i];
- }
- }
-
- return null;
- }
+ ///
+ /// A member of an extension block takes the block's type parameters through its grouping type, so those type
+ /// arguments are read as well.
+ ///
+ internal static ITypeSymbol? FindUnreachableInSignature(IMethodSymbol method, Compilation compilation) =>
+ FindUnreachable(method.TypeArguments, compilation) ?? FindUnreachable(method.ContainingType.TypeArguments, compilation);
/// Finds the first link of a selector lambda whose owner generated code cannot name.
/// The invocation arguments.
@@ -117,6 +126,23 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
return null;
}
+ /// Finds the first of a list of types that generated code cannot name.
+ /// The types.
+ /// The consumer compilation.
+ /// The unreachable type, or null when every type is reachable.
+ private static ITypeSymbol? FindUnreachable(ImmutableArray types, Compilation compilation)
+ {
+ for (var i = 0; i < types.Length; i++)
+ {
+ if (!IsReachable(types[i], compilation))
+ {
+ return types[i];
+ }
+ }
+
+ return null;
+ }
+
/// Determines whether the generator produces code for calls to a binding method of this name.
/// The method name.
/// for a generated entry point; runtime-only APIs such as WhenAnyDynamic are not.
diff --git a/src/ReactiveUI.Binding.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/ReactiveUI.Binding.SourceGenerators/AnalyzerReleases.Unshipped.md
index cc113f89..ffed4bd6 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/src/ReactiveUI.Binding.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -2,24 +2,24 @@
Rule ID | Category | Severity | Notes
-------------|----------|----------|-----------------------------------------------------------------------------------------
- RXUIBIND001 | Usage | Info | Expression must be inline lambda for compile-time optimization
+ RXUIBIND001 | Usage | Error | Expression must be inline lambda for compile-time optimization
RXUIBIND002 | Usage | Warning | Type has no observable properties
- RXUIBIND003 | Usage | Warning | Expression contains private/protected member
+ RXUIBIND003 | Usage | Error | Expression contains private/protected member
RXUIBIND004 | Usage | Warning | Type does not support before-change notifications
RXUIBIND005 | Usage | Info | Source type implements INotifyDataErrorInfo; validation binding requires runtime engine
- RXUIBIND006 | Usage | Warning | Expression contains unsupported path segment (indexer, static field, read-only leaf field, or method call)
+ RXUIBIND006 | Usage | Error | Expression contains unsupported path segment (indexer, static field, read-only leaf field, or method call)
RXUIBIND007 | Usage | Warning | Control has no bindable event
RXUIBIND008 | Usage | Warning | Property is not an IInteraction
- RXUIBIND009 | Usage | Warning | Generated binding dispatch is out of reach for this file
+ RXUIBIND009 | Usage | Error | Generated binding dispatch is out of reach for this file
RXUIBIND010 | Usage | Warning | Observed path passes through a type that raises no notification
RXUIBIND011 | Usage | Warning | Binding call resolved to ReactiveUI's own mixin
- RXUIBIND012 | Usage | Warning | ToProperty source raises no notification generated code can reach
- RXUIBIND013 | Usage | Warning | ToProperty property must be named directly
+ RXUIBIND012 | Usage | Error | ToProperty source raises no notification generated code can reach
+ RXUIBIND013 | Usage | Error | ToProperty property must be named directly
RXUIBIND014 | Usage | Error | Name the ToProperty initial value below C# 13
- RXUIBIND015 | Usage | Warning | Binding call names a type generated code cannot reach
- RXUIBIND016 | Usage | Warning | Binding call is made through a type parameter
+ RXUIBIND015 | Usage | Error | Binding call names a type generated code cannot reach
+ RXUIBIND016 | Usage | Error | 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
RXUIBIND020 | Usage | Info | View is registered only in the service locator
- RXUIBIND021 | Usage | Warning | Binding call has no generated binding
+ RXUIBIND021 | Usage | Error | Binding call has no generated binding
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedCallSiteEmitter.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedCallSiteEmitter.cs
new file mode 100644
index 00000000..c4fd10d2
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedCallSiteEmitter.cs
@@ -0,0 +1,432 @@
+// 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 System.Runtime.CompilerServices;
+using ReactiveUI.Binding.SourceGenerators.Models;
+
+namespace ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+
+///
+/// Writes the generated code for call sites whose types only the caller's own partial class can name, and the generic
+/// interceptors that claim them.
+///
+///
+///
+/// Each API writes these call sites exactly as it writes any other, into a file of its own. That file's members move
+/// into a private class nested in the caller's partial class, where the private types are in scope. Their interception
+/// attributes come off, because an interceptor has to sit in the generated namespace, and so does the receiver's
+/// this, because an extension method cannot sit in a nested class.
+///
+///
+/// The generated namespace then gets one generic interceptor per hosted method. It repeats the called method's
+/// signature with its type parameters, so it names no private type. It forwards to an entry point in the caller's class,
+/// which casts each argument to the type the call was closed over and calls the moved method.
+///
+///
+internal static class HostedCallSiteEmitter
+{
+ /// The class, nested in the caller's class, that holds the moved members.
+ internal const string BindingsClassName = "__ReactiveUIHostedBindings";
+
+ /// The class, nested in the caller's class, that holds the entry points the interceptors call.
+ internal const string EntriesClassName = "__ReactiveUIHostedEntries";
+
+ /// The modifier that makes a method an extension method.
+ private const string ReceiverModifier = "this ";
+
+ /// Writes one API's file, with the call sites only the caller's class can name moved there.
+ /// The per-call-site model this API extracts.
+ /// Every call site of the API.
+ /// The consumer compilation's language-feature snapshot.
+ /// The API's emitter, which writes a whole file for the call sites it is given.
+ /// The file's text, or null when the API claimed no call site.
+ ///
+ /// A build that writes dispatch overloads has no interceptor to claim a hosted call site with, so those call
+ /// sites are dropped and RXUIBIND015 fails the build at each of them.
+ ///
+ internal static string? Compose(
+ ImmutableArray invocations,
+ in LanguageFeatures features,
+ Func, LanguageFeatures, string?> emit)
+ where T : IClaimableCallSite
+ {
+ if (!AnyHosted(invocations))
+ {
+ return emit(invocations, features);
+ }
+
+ var unhosted = ImmutableArray.CreateBuilder(invocations.Length);
+ var hosts = new List();
+ var byHost = new Dictionary.Builder>(StringComparer.Ordinal);
+ for (var i = 0; i < invocations.Length; i++)
+ {
+ var invocation = invocations[i];
+ if (invocation.Host is not { } host)
+ {
+ unhosted.Add(invocation);
+ continue;
+ }
+
+ var key = GroupKey(host);
+ if (!byHost.TryGetValue(key, out var group))
+ {
+ group = ImmutableArray.CreateBuilder();
+ byHost[key] = group;
+ hosts.Add(key);
+ }
+
+ group.Add(invocation);
+ }
+
+ var main = unhosted.Count == 0 ? null : emit(unhosted.ToImmutable(), features);
+ if (!features.SupportsInterceptors)
+ {
+ return main;
+ }
+
+ var writer = SourceWriter.Rent();
+ var claimsAny = false;
+ foreach (var hostName in hosts)
+ {
+ claimsAny |= AppendHost(writer, byHost[hostName].ToImmutable(), features, emit);
+ }
+
+ var hosted = writer.ToStringAndReturn();
+ if (!claimsAny)
+ {
+ return main;
+ }
+
+ return main is null ? AppendFileHeader(features) + hosted : main + hosted;
+ }
+
+ /// Determines whether any call site is hosted in the caller's class.
+ /// The per-call-site model this API extracts.
+ /// The call sites.
+ /// when at least one is.
+ private static bool AnyHosted(ImmutableArray invocations)
+ where T : IClaimableCallSite
+ {
+ if (invocations.IsDefaultOrEmpty)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < invocations.Length; i++)
+ {
+ if (invocations[i].Host is not null)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// Names the group of moved call sites that share one destination: the same class, generic over the same type parameters.
+ /// The moved call.
+ /// The group's key.
+ private static string GroupKey(HostedCall call)
+ {
+ var key = new PooledStringBuilder().Append(call.HostTypeFullName ?? string.Empty);
+ foreach (var parameter in call.WrapperTypeParameters)
+ {
+ _ = key.Append('|').Append(parameter);
+ }
+
+ foreach (var clause in call.WrapperConstraints)
+ {
+ _ = key.Append('|').Append(clause);
+ }
+
+ return key.ToStringAndReturn();
+ }
+
+ /// Settles where one group's moved code is declared.
+ /// A call of the group.
+ /// The consumer compilation's language-feature snapshot.
+ /// The destination.
+ ///
+ /// A call that only needs to be generic stays in the generated class. Its entry class is generic over the caller's
+ /// type parameters, and named for them, so two groups with different ones never share a declaration.
+ ///
+ private static Destination DestinationOf(HostedCall call, in LanguageFeatures features)
+ {
+ var host = call.HostTypeFullName ?? $"global::{features.GeneratedNamespace}.{features.GeneratedClassName}";
+ var declaration = call.Declaration
+ ?? new PartialTypeDeclaration(features.GeneratedNamespace, new([$"static partial class {features.GeneratedClassName}"]));
+ if (!call.IsGeneric)
+ {
+ return new(declaration, EntriesClassName, $"{host}.{EntriesClassName}");
+ }
+
+ var entries = $"{EntriesClassName}_{CodeGeneratorHelpers.ComputeStableMethodSuffix(host, string.Empty, 0, GroupKey(call))}";
+ return new(
+ declaration,
+ $"{entries}<{string.Join(", ", call.WrapperTypeParameters)}>",
+ $"{host}.{entries}<{string.Join(", ", call.WrapperTypeArguments)}>");
+ }
+
+ /// Writes the start of a file whose only members are hosted.
+ /// The consumer compilation's language-feature snapshot.
+ /// The file header and imports.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static string AppendFileHeader(in LanguageFeatures features) =>
+ SourceWriter.Rent()
+ .FileHeader(features.EmitGeneratedCodeMarkers, features.SupportsNullable)
+ .BlankLine()
+ .Using("System")
+ .ToStringAndReturn();
+
+ /// Writes one caller class's hosted members, their entry points, and the interceptors that reach them.
+ /// The per-call-site model this API extracts.
+ /// The writer, outside any namespace.
+ /// The call sites hosted in the class.
+ /// The consumer compilation's language-feature snapshot.
+ /// The API's emitter.
+ /// when at least one call site was claimed.
+ private static bool AppendHost(
+ SourceWriter writer,
+ ImmutableArray invocations,
+ in LanguageFeatures features,
+ Func, LanguageFeatures, string?> emit)
+ where T : IClaimableCallSite
+ {
+ var source = emit(invocations, features);
+ if (source is null || !HostedSourceReader.TryRead(source, features.GeneratedClassName, out var read))
+ {
+ return false;
+ }
+
+ var call = invocations[0].Host!;
+ var destination = DestinationOf(call, features);
+ var methods = GroupByMethod(invocations, read.Claims, destination.EntriesReference);
+ if (methods.Count == 0)
+ {
+ return false;
+ }
+
+ AppendInterceptors(writer, methods, destination.EntriesReference, features);
+
+ _ = writer.BlankLine();
+ CodeGeneratorHelpers.OpenPartialDeclaration(writer, destination.Declaration);
+ _ = writer.Line($"internal static partial class {destination.EntriesDeclaration}");
+ if (call.WrapperConstraints.Length > 0)
+ {
+ _ = writer.Indent();
+ foreach (var clause in call.WrapperConstraints)
+ {
+ _ = writer.Line(clause);
+ }
+
+ _ = writer.Outdent();
+ }
+
+ _ = writer.OpenBlock();
+ foreach (var method in methods)
+ {
+ AppendEntry(writer, method);
+ }
+
+ _ = writer.OpenType($"private static partial class {BindingsClassName}");
+ foreach (var line in read.Members)
+ {
+ _ = line.Length == 0 ? writer.BlankLine() : writer.Line(line);
+ }
+
+ _ = writer.CloseBlock().CloseBlock();
+ CodeGeneratorHelpers.ClosePartialDeclaration(writer, destination.Declaration);
+
+ if (read.Trailing.Length > 0)
+ {
+ _ = writer.BlankLine().Lines(read.Trailing);
+ }
+
+ return true;
+ }
+
+ /// Gathers the claimed call sites under the moved method each one reaches.
+ /// The per-call-site model this API extracts.
+ /// The call sites hosted in one class.
+ /// The method each claimed call site's data names, as read from the API's output.
+ /// The class the entry points are declared in, as the interceptors name it.
+ /// One entry per moved method and signature, in the order the API wrote them.
+ private static List GroupByMethod(
+ ImmutableArray invocations,
+ Dictionary claims,
+ string entriesReference)
+ where T : IClaimableCallSite
+ {
+ var methods = new List();
+ var byKey = new Dictionary(StringComparer.Ordinal);
+ for (var i = 0; i < invocations.Length; i++)
+ {
+ var invocation = invocations[i];
+ var location = invocation.Interceptor;
+ if (!location.IsAvailable || !claims.TryGetValue(location.Data!, out var methodName))
+ {
+ continue;
+ }
+
+ var call = invocation.Host!;
+ var suffix = CodeGeneratorHelpers.ComputeStableMethodSuffix(entriesReference, methodName, 0, SignatureKey(call));
+ if (!byKey.TryGetValue(suffix, out var method))
+ {
+ method = new(methodName, suffix, call, []);
+ byKey[suffix] = method;
+ methods.Add(method);
+ }
+
+ method.Locations.Add(location);
+ }
+
+ return methods;
+ }
+
+ /// Writes a text that tells two signatures of the same moved method apart.
+ /// The hosted call.
+ /// The key.
+ private static string SignatureKey(HostedCall call)
+ {
+ var key = new PooledStringBuilder().Append(call.DeclaredReturnType);
+ foreach (var parameter in call.Parameters)
+ {
+ _ = key.Append('|').Append(parameter.ClosedType);
+ }
+
+ return key.ToStringAndReturn();
+ }
+
+ /// Writes the generic interceptors that claim one class's hosted call sites.
+ /// The writer, outside any namespace.
+ /// The moved methods and the call sites that reach each.
+ /// The class the entry points are declared in, as the interceptors name it.
+ /// The consumer compilation's language-feature snapshot.
+ private static void AppendInterceptors(
+ SourceWriter writer,
+ List methods,
+ string entriesReference,
+ in LanguageFeatures features)
+ {
+ _ = writer.BlankLine().OpenNamespace(features.GeneratedNamespace);
+ CodeGeneratorHelpers.OpenGeneratedClass(writer, features);
+
+ foreach (var method in methods)
+ {
+ foreach (var location in method.Locations)
+ {
+ InterceptorEmitter.AppendAttribute(writer, location);
+ }
+
+ var call = method.Call;
+ _ = writer.Append("internal static ").Append(call.DeclaredReturnType).Append(" __InterceptHosted_").Append(method.Suffix);
+ AppendSignature(writer, call, ReceiverModifier);
+ _ = writer.Indent()
+ .Append("=> ").Append(entriesReference).Append(".__Entry_").Append(method.Suffix);
+ AppendTypeArguments(writer, call);
+ _ = writer.Append('(');
+ for (var i = 0; i < call.Parameters.Length; i++)
+ {
+ _ = (i == 0 ? writer : writer.Append(", ")).Append(call.Parameters[i].Name);
+ }
+
+ _ = writer.Line(");").Outdent().BlankLine();
+ }
+
+ CodeGeneratorHelpers.AppendExtensionClassFooter(writer);
+ }
+
+ /// Writes the entry point an interceptor calls, which casts to the closed types and calls the moved method.
+ /// The writer, inside the entries class.
+ /// The moved method.
+ private static void AppendEntry(SourceWriter writer, HostedMethod method)
+ {
+ var call = method.Call;
+ _ = writer.Append("internal static ").Append(call.DeclaredReturnType).Append(" __Entry_").Append(method.Suffix);
+ AppendSignature(writer, call, string.Empty);
+ _ = writer.Indent().Append("=> ");
+ if (call.ReturnNeedsCast)
+ {
+ _ = writer.Append('(').Append(call.DeclaredReturnType).Append(")(object)");
+ }
+
+ _ = writer.Append(BindingsClassName).Append('.').Append(method.MethodName).Append('(');
+ for (var i = 0; i < call.Parameters.Length; i++)
+ {
+ var parameter = call.Parameters[i];
+ if (i > 0)
+ {
+ _ = writer.Append(", ");
+ }
+
+ _ = parameter.NeedsCast
+ ? writer.Append('(').Append(parameter.ClosedType).Append(")(object)").Append(parameter.Name)
+ : writer.Append(parameter.Name);
+ }
+
+ _ = writer.Line(");").Outdent().BlankLine();
+ }
+
+ /// Writes a hosted method's type parameters, parameters and constraints, as the called method declares them.
+ /// The writer, just after the method's name.
+ /// The hosted call.
+ /// What the first parameter starts with: this for an interceptor, nothing otherwise.
+ private static void AppendSignature(SourceWriter writer, HostedCall call, string receiverModifier)
+ {
+ AppendTypeArguments(writer, call);
+ _ = writer.OpenParameterList();
+ for (var i = 0; i < call.Parameters.Length; i++)
+ {
+ var parameter = call.Parameters[i];
+ _ = writer.Append(i == 0 ? receiverModifier : string.Empty).Append(parameter.DeclaredType).Append(' ').Append(parameter.Name);
+ _ = i == call.Parameters.Length - 1 ? writer.Line(")").Outdent() : writer.Line(",");
+ }
+
+ if (call.Constraints.Length == 0)
+ {
+ return;
+ }
+
+ _ = writer.Indent();
+ foreach (var clause in call.Constraints)
+ {
+ _ = writer.Line(clause);
+ }
+
+ _ = writer.Outdent();
+ }
+
+ /// Writes the called method's type parameters, in angle brackets.
+ /// The writer.
+ /// The hosted call.
+ private static void AppendTypeArguments(SourceWriter writer, HostedCall call)
+ {
+ if (call.TypeParameters.Length == 0)
+ {
+ return;
+ }
+
+ _ = writer.Append('<');
+ for (var i = 0; i < call.TypeParameters.Length; i++)
+ {
+ _ = (i == 0 ? writer : writer.Append(", ")).Append(call.TypeParameters[i]);
+ }
+
+ _ = writer.Append('>');
+ }
+
+ /// Where one group of moved call sites is declared.
+ /// The partial declarations that reach that class.
+ /// The entry class's name as it is declared, with its type parameters.
+ /// The entry class as the interceptors name it, with the type arguments they hand on.
+ private sealed record Destination(PartialTypeDeclaration Declaration, string EntriesDeclaration, string EntriesReference);
+
+ /// One moved method, one signature of it, and every call site that reaches it.
+ /// The name the API gave the method.
+ /// The name suffix of the interceptor and the entry point.
+ /// The signature of the method the call sites call.
+ /// The call sites.
+ private sealed record HostedMethod(string MethodName, string Suffix, HostedCall Call, List Locations);
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSource.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSource.cs
new file mode 100644
index 00000000..b5b0e822
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSource.cs
@@ -0,0 +1,11 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+
+/// The members of an API's file, read back so they can move into the caller's class.
+/// The member lines, without the indentation of the class they were written in.
+/// The interceptor each claimed call site's data names.
+/// Text the file declares after the generated namespace, which stays at namespace level.
+internal readonly record struct HostedSource(List Members, Dictionary Claims, string Trailing);
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSourceReader.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSourceReader.cs
new file mode 100644
index 00000000..5125e91b
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSourceReader.cs
@@ -0,0 +1,204 @@
+// 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.Runtime.CompilerServices;
+
+namespace ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+
+/// Reads an API's generated file back, so its members can move into the caller's partial class.
+///
+/// Every API writes its file through the same header and footer, one member per line and four spaces per level. The
+/// class's body is therefore the lines between its opening brace and the closing brace at its own indentation. While
+/// reading, each interception attribute is removed and remembered against the interceptor that follows it, and that
+/// interceptor loses the this on its receiver, because an extension method cannot sit in a nested class.
+///
+internal static class HostedSourceReader
+{
+ /// One level of indentation.
+ private const string Level = " ";
+
+ /// Reads an API's file.
+ /// The file.
+ /// The name of the class the API writes its members in.
+ /// The members, claims and trailing text.
+ /// when the file has the shape every API writes.
+ internal static bool TryRead(string source, string className, out HostedSource read)
+ {
+ read = default;
+ var lines = source.Split('\n');
+ if (!TryFindClassBody(lines, className, out var open, out var close, out var namespaceClose))
+ {
+ return false;
+ }
+
+ var memberIndent = $"{Indentation(lines[open])}{Level}";
+ var claims = new ClaimReader();
+ var members = new List(close - open);
+ for (var i = open + 1; i < close; i++)
+ {
+ var line = lines[i].StartsWith(memberIndent, StringComparison.Ordinal)
+ ? lines[i].Substring(memberIndent.Length)
+ : lines[i].TrimStart();
+ if (claims.TryKeep(line, out var kept))
+ {
+ members.Add(kept);
+ }
+ }
+
+ read = new(members, claims.Claims, Trailing(lines, namespaceClose));
+ return true;
+ }
+
+ /// Finds the generated class's braces and the namespace's closing brace.
+ /// The file's lines.
+ /// The name of the class the API writes its members in.
+ /// The line of the class's opening brace.
+ /// The line of the class's closing brace.
+ /// The line of the namespace's closing brace.
+ /// when all three were found.
+ private static bool TryFindClassBody(string[] lines, string className, out int open, out int close, out int namespaceClose)
+ {
+ var header = $"internal static partial class {className}";
+ open = -1;
+ close = -1;
+ namespaceClose = -1;
+ for (var i = 0; i + 1 < lines.Length && open < 0; i++)
+ {
+ if (lines[i].Trim() == header && lines[i + 1].Trim() == "{")
+ {
+ open = i + 1;
+ }
+ }
+
+ if (open < 0)
+ {
+ return false;
+ }
+
+ close = Array.IndexOf(lines, $"{Indentation(lines[open])}}}", open + 1);
+ namespaceClose = close < 0 ? -1 : Array.IndexOf(lines, "}", close + 1);
+ return namespaceClose >= 0;
+ }
+
+ /// Reads the whitespace a line starts with.
+ /// The line.
+ /// The leading whitespace.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static string Indentation(string line) => line.Substring(0, line.Length - line.TrimStart().Length);
+
+ /// Joins whatever the file declares after the generated namespace.
+ /// The file's lines.
+ /// The line of the namespace's closing brace.
+ /// The trailing text, or an empty string.
+ private static string Trailing(string[] lines, int namespaceClose) =>
+ namespaceClose + 1 < lines.Length
+ ? string.Join("\n", lines, namespaceClose + 1, lines.Length - namespaceClose - 1).Trim()
+ : string.Empty;
+
+ /// Removes interception attributes and remembers which interceptor each one claimed a call site for.
+ private sealed class ClaimReader
+ {
+ /// The start of an interception attribute, as the API emitters write it.
+ private const string AttributeStart = "[global::System.Runtime.CompilerServices.InterceptsLocation(";
+
+ /// The prefix every interceptor an API writes carries.
+ private const string InterceptorPrefix = "__Intercept_";
+
+ /// The modifier that makes a method an extension method.
+ private const string ReceiverModifier = "this ";
+
+ /// The receiver modifier as it reads when the parameter list starts on the method's line.
+ private const string InlineReceiverModifier = "(this ";
+
+ /// The data of the attributes read since the last interceptor.
+ private readonly List _pending = [];
+
+ /// Whether the last interceptor's receiver still carries this.
+ private bool _stripping;
+
+ /// Gets the interceptor each call site's data names.
+ internal Dictionary Claims { get; } = [with(StringComparer.Ordinal)];
+
+ /// Reads one member line.
+ /// The line, without the class's indentation.
+ /// The line to keep, rewritten when it is part of an interceptor's declaration.
+ /// when the line is an interception attribute and is dropped.
+ internal bool TryKeep(string line, out string kept)
+ {
+ kept = line;
+ var trimmed = line.TrimStart();
+ if (trimmed.StartsWith(AttributeStart, StringComparison.Ordinal))
+ {
+ _pending.Add(ReadData(trimmed));
+ return false;
+ }
+
+ var nameStart = _pending.Count > 0 ? trimmed.IndexOf(InterceptorPrefix, StringComparison.Ordinal) : -1;
+ if (nameStart >= 0)
+ {
+ var name = ReadName(trimmed, nameStart);
+ foreach (var data in _pending)
+ {
+ Claims[data] = name;
+ }
+
+ _pending.Clear();
+ _stripping = true;
+ }
+
+ if (_stripping)
+ {
+ kept = StripReceiver(line);
+ }
+
+ return true;
+ }
+
+ /// Reads the data an interception attribute names.
+ /// The attribute line.
+ /// The data, the text between its quotes.
+ private static string ReadData(string attribute)
+ {
+ var start = attribute.IndexOf('"') + 1;
+ return attribute.Substring(start, attribute.LastIndexOf('"') - start);
+ }
+
+ /// Reads an interceptor's name from its declaration line.
+ /// The declaration line.
+ /// Where the name starts.
+ /// The name.
+ private static string ReadName(string declaration, int start)
+ {
+ var end = start;
+ while (end < declaration.Length && (char.IsLetterOrDigit(declaration[end]) || declaration[end] == '_'))
+ {
+ end++;
+ }
+
+ return declaration.Substring(start, end - start);
+ }
+
+ /// Removes the this from the interceptor's receiver, once.
+ /// A line of the interceptor's declaration.
+ /// The line, without the modifier.
+ private string StripReceiver(string line)
+ {
+ var trimmed = line.TrimStart();
+ if (trimmed.StartsWith(ReceiverModifier, StringComparison.Ordinal))
+ {
+ _stripping = false;
+ return line.Remove(line.Length - trimmed.Length, ReceiverModifier.Length);
+ }
+
+ var inline = line.IndexOf(InlineReceiverModifier, StringComparison.Ordinal);
+ if (inline < 0)
+ {
+ return line;
+ }
+
+ _stripping = false;
+ return line.Remove(inline + 1, ReceiverModifier.Length);
+ }
+ }
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InterceptorEmitter.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InterceptorEmitter.cs
index 04289eb2..ce44b7b4 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InterceptorEmitter.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InterceptorEmitter.cs
@@ -2,6 +2,7 @@
// 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 System.Runtime.CompilerServices;
using ReactiveUI.Binding.SourceGenerators.Models;
@@ -134,7 +135,9 @@ internal static void GenerateInterceptors(
}
_ = builder.Append($"internal static {GeneratedTypeNames.IObservable}<").Append(first.ReturnTypeFullName)
- .Append("> __Intercept_").Append(methodPrefix).Append('_').Append(entry.Key).OpenParameterList();
+ .Append("> __Intercept_").Append(methodPrefix).Append('_').Append(entry.Key);
+ AppendTypeParameterList(builder, first.GenericResult);
+ _ = builder.OpenParameterList();
appendParameterList(builder, first, in features);
@@ -146,6 +149,81 @@ internal static void GenerateInterceptors(
}
}
+ /// Writes the type parameters of an interceptor that is generic over its call's selector result.
+ /// The writer, just after the interceptor's name.
+ /// Where the result sits among the called method's type arguments.
+ ///
+ /// The compiler substitutes the call's type arguments into a generic interceptor before it compares the two
+ /// signatures, so the interceptor takes as many type parameters as the called method. Only the result's is used:
+ /// every other parameter is written with the type the call was closed over, which is the same after substitution.
+ ///
+ internal static void AppendTypeParameterList(SourceWriter builder, in GenericResult genericResult)
+ {
+ if (!genericResult.IsGeneric)
+ {
+ return;
+ }
+
+ _ = builder.Append('<');
+ for (var i = 0; i < genericResult.Arity; i++)
+ {
+ if (i > 0)
+ {
+ _ = builder.Append(", ");
+ }
+
+ _ = i == genericResult.Ordinal
+ ? builder.Append(GenericResult.TypeParameterName)
+ : builder.Append("__T").Append(i);
+ }
+
+ _ = builder.Append('>');
+ }
+
+ /// Writes the type parameter of an observation method that is generic over its selector result.
+ /// The writer, just after the method's name.
+ /// Where the result sits, or a default value when every type is named.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static void AppendResultTypeParameter(SourceWriter builder, in GenericResult genericResult)
+ {
+ if (genericResult.IsGeneric)
+ {
+ _ = builder.Append('<').Append(GenericResult.TypeParameterName).Append('>');
+ }
+ }
+
+ /// Drops the call sites only an interceptor can claim, when the build writes dispatch overloads instead.
+ /// The per-call-site model this API extracts.
+ /// The call sites of one API.
+ /// Reads where a call site's result sits when generated code cannot name it.
+ /// The consumer compilation's language-feature snapshot.
+ /// The call sites the build can claim.
+ ///
+ /// A dispatch overload has to name its result type, so a call whose result generated code cannot name gets no
+ /// overload. It reaches the runtime stub instead, and RXUIBIND015 fails the build at the call.
+ ///
+ internal static ImmutableArray ClaimableBy(
+ ImmutableArray invocations,
+ Func genericResultOf,
+ in LanguageFeatures features)
+ {
+ if (features.SupportsInterceptors || invocations.IsDefaultOrEmpty)
+ {
+ return invocations;
+ }
+
+ var builder = ImmutableArray.CreateBuilder(invocations.Length);
+ for (var i = 0; i < invocations.Length; i++)
+ {
+ if (!genericResultOf(invocations[i]).IsGeneric)
+ {
+ builder.Add(invocations[i]);
+ }
+ }
+
+ return builder.Count == invocations.Length ? invocations : builder.ToImmutable();
+ }
+
/// Gathers call sites under the generated method each of them reaches.
/// The per-call-site model this API extracts.
/// The call sites of one group.
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs
index be846dec..fe823eab 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs
@@ -88,7 +88,7 @@ internal static bool IsINPChanging(ClassBindingInfo? classInfo) =>
in LanguageFeatures features,
string methodPrefix) =>
CodeGeneratorHelpers.GenerateDispatchFile(
- invocations,
+ InterceptorEmitter.ClaimableBy(invocations, static x => x.GenericResult, features),
features,
GroupByTypeSignature,
(sb, group, snapshot) => GenerateGroup(
@@ -114,7 +114,9 @@ internal static void GenerateObservationMethod(
string prefix)
{
_ = sb.Append($"private static {IObservable}<").Append(inv.ReturnTypeFullName).Append("> __").Append(prefix).Append('_')
- .Append(suffix).Append('(').Append(inv.SourceTypeFullName).Append(" obj");
+ .Append(suffix);
+ InterceptorEmitter.AppendResultTypeParameter(sb, inv.GenericResult);
+ _ = sb.Append('(').Append(inv.SourceTypeFullName).Append(" obj");
if (inv.HasSelector)
{
_ = sb.Append(", ").Append(GetSelectorType(inv)).Append(" selector");
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyCodeGenerator.cs
index 9629ff35..6bf530e7 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyCodeGenerator.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyCodeGenerator.cs
@@ -29,7 +29,7 @@ internal static class WhenAnyCodeGenerator
ImmutableArray allClasses,
in LanguageFeatures features) =>
CodeGeneratorHelpers.GenerateDispatchFile(
- invocations,
+ InterceptorEmitter.ClaimableBy(invocations, static x => x.GenericResult, features),
features,
ObservationCodeGenerator.GroupByTypeSignature,
(sb, group, snapshot) => EmitGroup(sb, group, allClasses, snapshot));
@@ -115,8 +115,9 @@ internal static void GenerateObservationMethod(
ClassBindingInfo? classInfo,
string suffix)
{
- _ = sb.Append($"private static {GeneratedTypeNames.IObservable}<").Append(inv.ReturnTypeFullName).Append("> __WhenAny_").Append(suffix)
- .Append('(').Append(inv.SourceTypeFullName).Append(" obj, ").Append(GetWhenAnySelectorType(inv)).Line(" selector)")
+ _ = sb.Append($"private static {GeneratedTypeNames.IObservable}<").Append(inv.ReturnTypeFullName).Append("> __WhenAny_").Append(suffix);
+ InterceptorEmitter.AppendResultTypeParameter(sb, inv.GenericResult);
+ _ = sb.Append('(').Append(inv.SourceTypeFullName).Append(" obj, ").Append(GetWhenAnySelectorType(inv)).Line(" selector)")
.OpenBlock();
if (inv.PropertyPaths.Length == 1)
diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyObservableCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyObservableCodeGenerator.cs
index 8aa7bdde..30a4dd8e 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyObservableCodeGenerator.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/WhenAnyObservableCodeGenerator.cs
@@ -40,7 +40,7 @@ internal static class WhenAnyObservableCodeGenerator
ImmutableArray allClasses,
in LanguageFeatures features) =>
CodeGeneratorHelpers.GenerateDispatchFile(
- invocations,
+ InterceptorEmitter.ClaimableBy(invocations, static x => x.GenericResult, features),
features,
GroupByTypeSignature,
(sb, group, snapshot) => EmitGroup(sb, group, allClasses, snapshot));
@@ -90,7 +90,9 @@ internal static void GenerateObservationMethod(
string suffix)
{
_ = sb.Append($"private static {IObservable}<").Append(inv.ReturnTypeFullName).Append("> __WhenAnyObservable_")
- .Append(suffix).Append('(').Append(inv.SourceTypeFullName).Append(" obj");
+ .Append(suffix);
+ InterceptorEmitter.AppendResultTypeParameter(sb, inv.GenericResult);
+ _ = sb.Append('(').Append(inv.SourceTypeFullName).Append(" obj");
if (inv.HasSelector)
{
_ = sb.Append(", ").Append(GetSelectorType(inv)).Append(" selector");
@@ -343,7 +345,9 @@ private static void GenerateInterceptors(SourceWriter sb, TypeGroup group, in La
}
_ = sb.Append($"internal static {IObservable}<").Append(first.ReturnTypeFullName)
- .Append("> __Intercept_WhenAnyObservable_").Append(entry.Key).OpenParameterList();
+ .Append("> __Intercept_WhenAnyObservable_").Append(entry.Key);
+ InterceptorEmitter.AppendTypeParameterList(sb, first.GenericResult);
+ _ = sb.OpenParameterList();
AppendParameterList(sb, first, supportsCallerArgExpr, supportsNullable, stubHasExpressionParameters);
diff --git a/src/ReactiveUI.Binding.SourceGenerators/DiagnosticWarnings.cs b/src/ReactiveUI.Binding.SourceGenerators/DiagnosticWarnings.cs
index 69bc35fe..0e94c665 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/DiagnosticWarnings.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/DiagnosticWarnings.cs
@@ -25,7 +25,7 @@ internal static class DiagnosticWarnings
"Expression argument must be an inline lambda expression for compile-time optimization. A variable or "
+ "method reference is not generated, so the call throws unless it names the Unsafe overload.",
UsageCategory,
- DiagnosticSeverity.Info,
+ DiagnosticSeverity.Error,
true,
NoneInlineLambdaDescription);
@@ -65,7 +65,7 @@ internal static class DiagnosticWarnings
"ToProperty source raises no notification generated code can reach",
"Generated code cannot raise change notifications for '{0}', so this ToProperty call generates nothing and throws when it runs",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
UnraisableToPropertySourceDescription);
@@ -75,7 +75,7 @@ internal static class DiagnosticWarnings
"ToProperty property must be named directly",
"Name the property as 'x => x.Property' or as a constant such as nameof(Property); this call generates nothing and throws when it runs",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
UnreadableToPropertyNameDescription);
@@ -93,9 +93,9 @@ internal static class DiagnosticWarnings
internal static readonly DiagnosticDescriptor UnreachableType = new(
"RXUIBIND015",
"Binding call names a type generated code cannot reach",
- "'{0}' is private or protected, so generated code cannot name it; this call generates nothing and throws when it runs",
+ "'{0}' is anonymous, file-local, private or protected, so no generated code claims this call; use an internal or public type, make the calling class partial, or call the Unsafe overload",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
UnreachableTypeDescription);
@@ -103,9 +103,9 @@ internal static class DiagnosticWarnings
internal static readonly DiagnosticDescriptor TypeParameterCall = new(
"RXUIBIND016",
"Binding call is made through a type parameter",
- "'{0}' is built from a type parameter, so generated code cannot name it; this call generates nothing and throws when it runs",
+ "'{0}' is built from a type parameter that no generated code can stand in for, so no generated code claims this call; call the Unsafe overload",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
TypeParameterCallDescription);
@@ -135,7 +135,7 @@ internal static class DiagnosticWarnings
"Binding call has no generated binding",
"'{0}' has no generated binding, so it throws at run time; name each member directly in the lambdas, declare a member another source generator adds as a partial property, or call {0}Unsafe",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
NoGeneratedBindingDescription);
@@ -169,7 +169,7 @@ internal static class DiagnosticWarnings
"Expression contains private or protected member",
"Expression accesses private or protected member '{0}' which cannot be observed by a generated extension method",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
PrivateMemberDescription);
@@ -202,7 +202,7 @@ internal static class DiagnosticWarnings
+ "this file's namespace '{1}' is not under it. Move the file under the root namespace, raise the language "
+ "version to 10 or later, or name the Unsafe overload to resolve the expression at run time.",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
DispatchOutOfReachDescription);
@@ -213,7 +213,7 @@ internal static class DiagnosticWarnings
"Expression contains '{0}' which is not a property or instance field access. Indexers, static fields, a read-only field at the end of "
+ "the path, and method calls are not generated, so the call throws unless it names the Unsafe overload.",
UsageCategory,
- DiagnosticSeverity.Warning,
+ DiagnosticSeverity.Error,
true,
UnsupportedPathSegmentDescription);
@@ -319,7 +319,10 @@ internal static class DiagnosticWarnings
+ "method it names. A call it cannot read still runs the runtime method, which throws. A generator reads your code "
+ "without any generator's output, so a member another source generator adds cannot be read. The members "
+ "ReactiveUI.SourceGenerators adds are the exception: their rules are known, so they are read. Declare other "
- + "generated members as partial properties, or call the Unsafe overload, which finds them by reflection.";
+ + "generated members as partial properties, or call the Unsafe overload, which finds them by reflection. A call "
+ + "through such a member is reported as a warning, because only the other generator could change it. Every "
+ + "other cause is one you can fix, such as a selector that computes a value or a call written through the "
+ + "class that declares the method, and fails the build.";
/// The string description of the service-locator-only view information.
private const string ServiceLocatorOnlyViewDescription =
@@ -352,15 +355,22 @@ internal static class DiagnosticWarnings
/// The string description of the type parameter call warning.
private const string TypeParameterCallDescription =
- "Generated overloads and interceptors name the closed types a call is made with. A call inside a generic "
- + "method or type whose types come from its type parameters names none that generated code can write, so "
- + "the call stays on the runtime stub. Call the Unsafe overload, which resolves the path at run time.";
+ "Generated code names the closed types a call is made with. With Roslyn 4.13 or newer and interceptors on, a "
+ + "call built from the calling code's type parameters is still generated, as code generic over them, when each "
+ + "type parameter the call uses, and each one its constraints name, is itself one of the called method's type "
+ + "arguments. Any other such call would throw when it runs, so the build fails at the call instead. Call the "
+ + "Unsafe overload, which resolves the path at run time.";
/// The string description of the unreachable type warning.
private const string UnreachableTypeDescription =
- "Generated overloads and interceptors live in a class of their own, so they can only name types that are "
- + "accessible from outside the types that declare them. A private or protected nested type, or a generic "
- + "closed over one, is out of reach. Make the type internal or public, or call the Unsafe overload.";
+ "Generated code lives in a class of its own, so it can only name types that are accessible from outside the "
+ + "types that declare them. An anonymous type, a file-local type, a private or protected nested type, or a "
+ + "type built from one is out of reach. With Roslyn 4.13 or newer and interceptors on, two kinds of call are "
+ + "still generated. A "
+ + "call whose only such type is its selector's result gets a generic interceptor. A call made inside a "
+ + "partial, non-generic class that can name every type gets its code added to that class. Any other call "
+ + "would throw when it runs, so the build fails at the call instead. Use an internal or public type, declare "
+ + "the calling class and the classes around it partial, or call the Unsafe overload.";
/// The string description of the silent path link warning.
private const string SilentPathLinkDescription =
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindToExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindToExtractor.cs
index 400b2f7d..8e8dd796 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindToExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindToExtractor.cs
@@ -30,7 +30,7 @@ internal static class BindToExtractor
var methodSymbol = ExtractorValidation.ExtractMethodSymbol(semanticModel.GetSymbolInfo(invocation, ct));
if (methodSymbol is null
|| !ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !CallSiteHosting.TryResolve(context, methodSymbol, ct, out var scope))
{
return null;
}
@@ -51,9 +51,9 @@ internal static class BindToExtractor
}
var targetPropertyArg = args[1].Expression;
- var targetPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(targetPropertyArg, semanticModel, ct);
+ var targetPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(targetPropertyArg, semanticModel, scope, ct);
var targetType = semanticModel.GetTypeInfo(args[0].Expression, ct).Type;
- var targetTypeName = ExtractorValidation.GetDeclarableTypeDisplayName(targetType);
+ var targetTypeName = scope.NameOf(targetType);
// A target the model cannot name leaves nothing to declare a member against, generated or otherwise.
if (targetTypeName is null)
@@ -88,7 +88,8 @@ internal static class BindToExtractor
hasConverterOverride,
targetExpressionText,
InterceptableLocationReader.Read(semanticModel, invocation, ct),
- ViewThreadPluginRegistry.InvokerFor(targetType, semanticModel.Compilation))
+ ViewThreadPluginRegistry.InvokerFor(targetType, semanticModel.Compilation),
+ scope.Call)
{
Conversion = ConversionPluginRegistry.Select(sourceValueType, targetValueType, semanticModel.Compilation),
SetMethod = SetMethodPluginRegistry.Select(sourceValueType, targetValueType),
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindingExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindingExtractor.cs
index e7117fd0..c85bfaaa 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindingExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/BindingExtractor.cs
@@ -39,7 +39,7 @@ internal static class BindingExtractor
// Verify this is our stub or generated method
if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !CallSiteHosting.TryResolve(context, methodSymbol, ct, out var scope))
{
return null;
}
@@ -55,15 +55,15 @@ internal static class BindingExtractor
var sourcePropertyArg = args[1].Expression;
var targetPropertyArg = args[2].Expression;
- var sourcePropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(sourcePropertyArg, semanticModel, ct);
- var targetPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(targetPropertyArg, semanticModel, ct);
+ var sourcePropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(sourcePropertyArg, semanticModel, scope, ct);
+ var targetPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(targetPropertyArg, semanticModel, scope, ct);
if (sourcePropertyPath is null || targetPropertyPath is null)
{
return null;
}
- if (ResolveBindingSides(memberAccess, args, methodName, semanticModel, ct) is not { } sides)
+ if (ResolveBindingSides(memberAccess, args, methodName, semanticModel, scope, ct) is not { } sides)
{
return null;
}
@@ -91,7 +91,8 @@ internal static class BindingExtractor
hasConverterOverride,
InterceptableLocationReader.Read(semanticModel, invocation, ct),
sides.SourceViewThreadInvoker,
- sides.TargetViewThreadInvoker)
+ sides.TargetViewThreadInvoker,
+ scope.Call)
{
ForwardConversion = ConversionPluginRegistry.Select(sourceValueType, targetValueType, semanticModel.Compilation),
ReverseConversion = isTwoWay ? ConversionPluginRegistry.Select(targetValueType, sourceValueType, semanticModel.Compilation) : null,
@@ -122,6 +123,7 @@ internal static class BindingExtractor
/// The invocation arguments.
/// The invoked method name.
/// The semantic model.
+ /// Where the generated code is declared, which decides the types it can name.
/// Cancellation token.
///
/// The fully qualified source and target type names, or when either side names a
@@ -132,12 +134,13 @@ internal static class BindingExtractor
SeparatedSyntaxList args,
string methodName,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
var receiverType = semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type;
var firstArgType = semanticModel.GetTypeInfo(args[0].Expression, ct).Type;
- var receiverTypeName = ExtractorValidation.GetDeclarableTypeDisplayName(receiverType);
- var firstArgTypeName = ExtractorValidation.GetDeclarableTypeDisplayName(firstArgType);
+ var receiverTypeName = scope.NameOf(receiverType);
+ var firstArgTypeName = scope.NameOf(firstArgType);
if (receiverTypeName is null || firstArgTypeName is null)
{
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteHosting.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteHosting.cs
new file mode 100644
index 00000000..f910dd25
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteHosting.cs
@@ -0,0 +1,477 @@
+// 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 ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+using ReactiveUI.Binding.SourceGenerators.Models;
+using ReactiveUI.Binding.SourceGenerators.Plugins.PropertyRaise;
+
+namespace ReactiveUI.Binding.SourceGenerators.Helpers;
+
+/// Decides where a call site's generated code is declared, so that it can name every type and member the call uses.
+///
+///
+/// Generated code normally sits in a class of its own, which can only name closed types visible outside the classes
+/// that declare them. On a build that intercepts, a call that needs more gets its code moved:
+///
+///
+///
+/// A call built from the caller's type parameters moves into a generic class whose type parameters stand in for them.
+/// Each of the caller's type parameters has to be one of the called method's own type arguments, so the interceptor can
+/// hand it on.
+///
+///
+/// A call that names a private or protected type or member moves into the caller's own partial class, which can name
+/// it. That class has to be partial, together with every class it is nested in, and none of them may be generic.
+///
+///
+///
+/// An interceptor in the generated namespace claims the call. It repeats the called method's signature with its type
+/// parameters, so it names nothing out of reach, and forwards to an entry point next to the moved code. A call that
+/// meets none of these rules stays out of reach, and an error fails the build at the call.
+///
+///
+internal static class CallSiteHosting
+{
+ /// The name prefix of the interceptor's own type parameters, which never clash with the caller's.
+ private const string TypeParameterPrefix = "__S";
+
+ /// The format the hosted signature is written in, which keeps nullable reference annotations.
+ private static readonly SymbolDisplayFormat SignatureFormat =
+ SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
+ SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
+
+ /// Resolves the scope a call site's generated code is written in.
+ /// The call site and its semantic model.
+ /// The resolved binding method.
+ /// Cancellation token.
+ /// The scope the generated code can name types in, which may move while the call is read.
+ /// when generated code can name every type argument of the call.
+ internal static bool TryResolve(in CallSiteContext context, IMethodSymbol method, CancellationToken ct, out ReachScope scope)
+ {
+ scope = ReachScope.ForCall(context, method, ct);
+ return scope.CanNameAll(method.TypeArguments);
+ }
+
+ /// Determines whether every type parameter a type is built from is one of a list.
+ /// The type.
+ /// The type parameters in scope.
+ /// when the type names no other type parameter.
+ internal static bool UsesOnly(ITypeSymbol type, ImmutableArray typeParameters) =>
+ type switch
+ {
+ ITypeParameterSymbol parameter => IndexOf(typeParameters, parameter) >= 0,
+ IArrayTypeSymbol array => UsesOnly(array.ElementType, typeParameters),
+ INamedTypeSymbol named => AllUseOnly(named.TypeArguments, typeParameters)
+ && (named.ContainingType is not { } containing || UsesOnly(containing, typeParameters)),
+ _ => true,
+ };
+
+ /// Moves a call's generated code out of a class of its own.
+ /// The call site and its semantic model.
+ /// The resolved binding method.
+ /// Whether the code moves into the caller's own partial class, rather than only a generic class.
+ /// Cancellation token.
+ /// Where the code moved to, or null when the call cannot move there.
+ internal static MovedScope? TryMove(in CallSiteContext context, IMethodSymbol method, bool intoCaller, CancellationToken ct)
+ {
+ if (CallerTypeParameters(method) is not { } callerTypeParameters || (!intoCaller && callerTypeParameters.IsEmpty))
+ {
+ return null;
+ }
+
+ INamedTypeSymbol? hostType = null;
+ PartialTypeDeclaration? declaration = null;
+ if (intoCaller)
+ {
+ hostType = FindHostType(context, context.SemanticModel.Compilation, ct);
+ declaration = hostType is null ? null : PartialTypeRaisePlugin.DescribeDeclaration(hostType);
+ if (declaration is null)
+ {
+ return null;
+ }
+ }
+
+ return Describe(method, hostType, declaration, callerTypeParameters) is { } call
+ ? new MovedScope(hostType, callerTypeParameters, call)
+ : null;
+ }
+
+ /// Writes the constraint clauses of a list of type parameters, with each type parameter renamed as given.
+ /// The type parameters, in order.
+ /// The name each type parameter, and each type parameter its constraints name, is written as.
+ /// The clauses, each starting with where; a type parameter with no constraint gets none.
+ ///
+ /// Generic code that repeats a parameter type such as IViewFor<TViewModel> only accepts a type argument
+ /// that meets the same constraints, so the constraints are repeated too.
+ ///
+ internal static EquatableArray ConstraintClauses(
+ ImmutableArray typeParameters,
+ Dictionary names)
+ {
+ var clauses = new List(typeParameters.Length);
+ foreach (var typeParameter in typeParameters)
+ {
+ if (TypeParameterConstraints.Of(typeParameter, names).Format() is { } constraints)
+ {
+ clauses.Add($"where {names[typeParameter]} : {constraints}");
+ }
+ }
+
+ return new([.. clauses]);
+ }
+
+ /// Writes a type, with each type parameter in a map written by its mapped name.
+ /// The type.
+ /// The names to write type parameters by.
+ /// The type's text.
+ internal static string Render(ITypeSymbol type, Dictionary names)
+ {
+ var text = new PooledStringBuilder();
+ foreach (var part in type.ToDisplayParts(SignatureFormat))
+ {
+ _ = part.Symbol is ITypeParameterSymbol parameter && names.TryGetValue(parameter, out var name)
+ ? text.Append(name)
+ : text.Append(part.ToString());
+ }
+
+ return text.ToStringAndReturn();
+ }
+
+ /// Collects the caller's type parameters a call is closed over, when each can be handed on by the interceptor.
+ /// The resolved binding method.
+ ///
+ /// The type parameters in the order the call first names them, or null when one of them is not itself one of the
+ /// method's type arguments, or when their constraints name a type parameter outside the list.
+ ///
+ private static ImmutableArray? CallerTypeParameters(IMethodSymbol method)
+ {
+ var found = new List();
+ foreach (var argument in method.TypeArguments)
+ {
+ Collect(argument, found);
+ }
+
+ var collected = found.ToImmutableArray();
+ foreach (var parameter in collected)
+ {
+ if (IndexOf(method.TypeArguments, parameter) < 0)
+ {
+ return null;
+ }
+
+ foreach (var constraint in parameter.ConstraintTypes)
+ {
+ if (!UsesOnly(constraint, collected))
+ {
+ return null;
+ }
+ }
+ }
+
+ return collected;
+ }
+
+ /// Adds every type parameter a type is built from to a list, once each.
+ /// The type.
+ /// The list.
+ private static void Collect(ITypeSymbol type, List found)
+ {
+ if (type is ITypeParameterSymbol parameter)
+ {
+ AddOnce(found, parameter);
+ }
+ else if (type is IArrayTypeSymbol array)
+ {
+ Collect(array.ElementType, found);
+ }
+ else if (type is INamedTypeSymbol named)
+ {
+ foreach (var argument in named.TypeArguments)
+ {
+ Collect(argument, found);
+ }
+
+ if (named.ContainingType is { } containing)
+ {
+ Collect(containing, found);
+ }
+ }
+ }
+
+ /// Adds a type parameter to a list unless it is already there.
+ /// The list.
+ /// The type parameter.
+ private static void AddOnce(List found, ITypeParameterSymbol parameter)
+ {
+ for (var i = 0; i < found.Count; i++)
+ {
+ if (SymbolEqualityComparer.Default.Equals(found[i], parameter))
+ {
+ return;
+ }
+ }
+
+ found.Add(parameter);
+ }
+
+ /// Finds a symbol in a list, by symbol equality.
+ /// The kind of symbol.
+ /// The list.
+ /// The symbol.
+ /// Its position, or -1.
+ private static int IndexOf(ImmutableArray symbols, ISymbol symbol)
+ where T : ISymbol
+ {
+ for (var i = 0; i < symbols.Length; i++)
+ {
+ if (SymbolEqualityComparer.Default.Equals(symbols[i], symbol))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /// Determines whether every type in a list names only the given type parameters.
+ /// The types.
+ /// The type parameters in scope.
+ /// when they do.
+ private static bool AllUseOnly(ImmutableArray types, ImmutableArray typeParameters)
+ {
+ for (var i = 0; i < types.Length; i++)
+ {
+ if (!UsesOnly(types[i], typeParameters))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// Finds the innermost class around a call site that the generated namespace can reach.
+ /// The call site and its semantic model.
+ /// The consumer compilation.
+ /// Cancellation token.
+ /// The class, or null when it is file-local or a generic class encloses the call before it is found.
+ ///
+ /// A binding call always sits in a member, so it always has a class around it, and the outermost class is visible
+ /// to its own assembly. A private class nested in the caller's class is skipped outward: the interceptor has to
+ /// call into the class it picks, so that class has to be visible from the generated namespace.
+ ///
+ private static INamedTypeSymbol? FindHostType(in CallSiteContext context, Compilation compilation, CancellationToken ct)
+ {
+ var type = context.SemanticModel.GetEnclosingSymbol(context.Node.SpanStart, ct)!.ContainingType;
+ while (!type.IsGenericType && !compilation.IsSymbolAccessibleWithin(type, compilation.Assembly))
+ {
+ type = type.ContainingType;
+ }
+
+ // A file-local class cannot be extended from a generated file, which is never the same file.
+ return type.IsGenericType || ExtractorValidation.ContainsNamelessType(type) ? null : type;
+ }
+
+ /// Describes the method a moved call site calls, in the terms its generic interceptor needs.
+ /// The resolved binding method, as the call closes it.
+ /// The caller's class the code moves into, or null for the generated class.
+ /// The partial declarations that reach the caller's class.
+ /// The caller's type parameters the moved code is generic over.
+ /// The description, or null when the method is not a classic extension method.
+ ///
+ /// A member of an extension block is not a classic extension method, and an interceptor for it would have to take
+ /// the block's type parameters as well, so it does not move.
+ ///
+ private static HostedCall? Describe(
+ IMethodSymbol method,
+ INamedTypeSymbol? hostType,
+ PartialTypeDeclaration? declaration,
+ ImmutableArray callerTypeParameters)
+ {
+ var closed = method.GetConstructedReducedFrom() ?? method;
+ var definition = closed.OriginalDefinition;
+ if (!definition.IsExtensionMethod)
+ {
+ return null;
+ }
+
+ // The interceptor's type parameters are renamed so they never clash with the caller's, which the moved code keeps.
+ var stubNames = new Dictionary(SymbolEqualityComparer.Default);
+ var typeParameters = new string[definition.TypeParameters.Length];
+ for (var i = 0; i < typeParameters.Length; i++)
+ {
+ typeParameters[i] = $"{TypeParameterPrefix}{i}";
+ stubNames[definition.TypeParameters[i]] = typeParameters[i];
+ }
+
+ // Each caller type parameter is handed on as the interceptor type parameter the call closed over it.
+ var callerNames = new Dictionary(SymbolEqualityComparer.Default);
+ var handedOn = new Dictionary(SymbolEqualityComparer.Default);
+ var wrapperParameters = new string[callerTypeParameters.Length];
+ var wrapperArguments = new string[callerTypeParameters.Length];
+ for (var i = 0; i < callerTypeParameters.Length; i++)
+ {
+ wrapperParameters[i] = callerTypeParameters[i].Name;
+ wrapperArguments[i] = typeParameters[IndexOf(closed.TypeArguments, callerTypeParameters[i])];
+ callerNames[callerTypeParameters[i]] = wrapperParameters[i];
+ handedOn[callerTypeParameters[i]] = wrapperArguments[i];
+ }
+
+ var parameters = new HostedParameter[definition.Parameters.Length];
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ parameters[i] = new(
+ definition.Parameters[i].Name,
+ Render(definition.Parameters[i].Type, stubNames),
+ Render(closed.Parameters[i].Type, callerNames));
+ }
+
+ return new(
+ hostType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ declaration,
+ new(typeParameters),
+ InterceptorConstraints(definition, closed, stubNames, handedOn),
+ Render(definition.ReturnType, stubNames),
+ Render(closed.ReturnType, callerNames),
+ new(parameters))
+ {
+ WrapperTypeParameters = new(wrapperParameters),
+ WrapperConstraints = ConstraintClauses(callerTypeParameters, callerNames),
+ WrapperTypeArguments = new(wrapperArguments),
+ };
+ }
+
+ /// Writes the interceptor's constraint clauses: the called method's, and those of each caller type parameter it hands on.
+ /// The called method as declared.
+ /// The called method as the call closes it.
+ /// The interceptor's name for each of the method's type parameters.
+ /// The interceptor's name for each caller type parameter.
+ /// The clauses.
+ ///
+ /// The moved code is generic over the caller's type parameters with their constraints, so the interceptor type
+ /// parameter that stands in for one has to meet them too.
+ ///
+ private static EquatableArray InterceptorConstraints(
+ IMethodSymbol definition,
+ IMethodSymbol closed,
+ Dictionary stubNames,
+ Dictionary handedOn)
+ {
+ var clauses = new List(definition.TypeParameters.Length);
+ for (var i = 0; i < definition.TypeParameters.Length; i++)
+ {
+ var constraints = TypeParameterConstraints.Of(definition.TypeParameters[i], stubNames);
+ if (closed.TypeArguments[i] is ITypeParameterSymbol caller)
+ {
+ constraints = constraints.With(TypeParameterConstraints.Of(caller, handedOn));
+ }
+
+ if (constraints.Format() is { } text)
+ {
+ clauses.Add($"where {stubNames[definition.TypeParameters[i]]} : {text}");
+ }
+ }
+
+ return new([.. clauses]);
+ }
+
+ /// Where a call's generated code moved to.
+ /// The caller's class, or null when the code stays in the generated class.
+ /// The caller's type parameters the moved code is generic over.
+ /// The call, described for the interceptor that claims it.
+ internal sealed record MovedScope(INamedTypeSymbol? Host, ImmutableArray TypeParameters, HostedCall Call);
+
+ /// The constraints on one type parameter, as written in a where clause.
+ /// The kind constraint, such as class or struct, or null.
+ /// The constraint types, written out.
+ /// Whether the type parameter has a constructor constraint.
+ internal sealed record TypeParameterConstraints(string? Kind, List Types, bool HasConstructor)
+ {
+ /// Reads a type parameter's constraints.
+ /// The type parameter.
+ /// The names to write type parameters by.
+ /// The constraints.
+ internal static TypeParameterConstraints Of(ITypeParameterSymbol typeParameter, Dictionary names)
+ {
+ var types = new List(typeParameter.ConstraintTypes.Length);
+ foreach (var constraintType in typeParameter.ConstraintTypes)
+ {
+ types.Add(Render(constraintType, names));
+ }
+
+ return new(KindOf(typeParameter), types, typeParameter.HasConstructorConstraint);
+ }
+
+ /// Combines these constraints with another set on the same type argument.
+ /// The other set.
+ /// The combined set.
+ ///
+ /// The type argument already meets both, because the call compiled, so the sets never conflict. A plain class
+ /// constraint is kept over an annotated or a not-null one, which it implies.
+ ///
+ internal TypeParameterConstraints With(TypeParameterConstraints other)
+ {
+ var types = new List(Types);
+ foreach (var type in other.Types)
+ {
+ if (!types.Contains(type))
+ {
+ types.Add(type);
+ }
+ }
+
+ return new(CombineKinds(Kind, other.Kind), types, HasConstructor || other.HasConstructor);
+ }
+
+ /// Writes the constraints in the order C# requires.
+ /// The constraints, comma separated, or null when there are none.
+ internal string? Format()
+ {
+ var parts = new List();
+ if (Kind is not null)
+ {
+ parts.Add(Kind);
+ }
+
+ parts.AddRange(Types);
+
+ // A value type always has a constructor, and C# refuses the constraint beside struct or unmanaged.
+ if (HasConstructor && Kind is not ("struct" or "unmanaged"))
+ {
+ parts.Add("new()");
+ }
+
+ return parts.Count == 0 ? null : string.Join(", ", parts);
+ }
+
+ /// Picks the kind constraint that implies the other.
+ /// The first kind, or null.
+ /// The second kind, or null.
+ /// The stricter kind.
+ private static string? CombineKinds(string? first, string? second)
+ {
+ if (first is null || first == second)
+ {
+ return second ?? first;
+ }
+
+ return second is null || first is not ("class?" or "notnull") ? first : second;
+ }
+
+ /// Reads a type parameter's kind constraint.
+ /// The type parameter.
+ /// The constraint, or null when it has none.
+ private static string? KindOf(ITypeParameterSymbol typeParameter) =>
+ typeParameter switch
+ {
+ { HasReferenceTypeConstraint: true } => typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" : "class",
+ { HasUnmanagedTypeConstraint: true } => "unmanaged",
+ { HasValueTypeConstraint: true } => "struct",
+ { HasNotNullConstraint: true } => "notnull",
+ _ => null,
+ };
+ }
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/CommandExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CommandExtractor.cs
index ebd35b08..644fc9a9 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/CommandExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/CommandExtractor.cs
@@ -41,14 +41,11 @@ internal static class CommandExtractor
var semanticModel = context.SemanticModel;
var methodSymbol = ExtractorValidation.ExtractMethodSymbol(semanticModel.GetSymbolInfo(invocation, ct));
- if (methodSymbol is null)
- {
- return null;
- }
// Verify this is our stub or generated method
- if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ if (methodSymbol is null
+ || !ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
+ || !CallSiteHosting.TryResolve(context, methodSymbol, ct, out var scope))
{
return null;
}
@@ -59,21 +56,22 @@ internal static class CommandExtractor
// The command path comes from the 2nd argument, the control path from the 3rd.
var commandPropertyArg = args[1].Expression;
var controlPropertyArg = args[2].Expression;
- var commandPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(commandPropertyArg, semanticModel, ct);
- var controlPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(controlPropertyArg, semanticModel, ct);
+ var commandPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(commandPropertyArg, semanticModel, scope, ct);
+ var controlPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(controlPropertyArg, semanticModel, scope, ct);
if (commandPropertyPath is null || controlPropertyPath is null)
{
return null;
}
- if (ResolveBindCommandSides(memberAccess, args, semanticModel, ct) is not { } sides)
+ // Determine parameter overload (Expression vs IObservable withParameter). A parameter path the generator cannot
+ // read would be dropped, so the call is left to fail the build instead.
+ var parameterOverload = DetectParameterOverload(methodSymbol, args, semanticModel, scope, ct);
+ if (parameterOverload is { HasExpressionParameter: true, ParameterPropertyPath: null }
+ || ResolveBindCommandSides(memberAccess, args, semanticModel, scope, ct) is not { } sides)
{
return null;
}
- // Determine parameter overload (Expression vs IObservable withParameter)
- var parameterOverload = DetectParameterOverload(methodSymbol, args, semanticModel, ct);
-
var controlBinding = ResolveControlBinding(methodSymbol, args, controlPropertyArg, semanticModel, ct);
return new(
@@ -100,7 +98,8 @@ internal static class CommandExtractor
controlBinding.Capabilities.HasCommandParameter,
controlBinding.Capabilities.HasEnabled,
InterceptableLocationReader.Read(semanticModel, invocation, ct),
- sides.ViewThreadInvoker)
+ sides.ViewThreadInvoker,
+ scope.Call)
{ HasExplicitEvent = controlBinding.HasExplicitEvent, NativeCommand = controlBinding.NativeCommand, ControlViewThreadInvoker = controlBinding.ViewThreadInvoker };
}
@@ -112,14 +111,31 @@ internal static class CommandExtractor
/// The parameter property path, leaf type name, and normalized expression text when a supported
/// lambda is found; otherwise, .
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ParameterLambda? FindParameterLambda(
+ SeparatedSyntaxList args,
+ SemanticModel semanticModel,
+ CancellationToken ct) =>
+ FindParameterLambda(args, semanticModel, ReachScope.Assembly(semanticModel.Compilation), ct);
+
+ /// Searches invocation arguments for a valid withParameter lambda expression.
+ /// The argument list from the invocation.
+ /// The semantic model used to resolve lambda property paths.
+ /// Where the generated code that reads the path is declared.
+ /// The token used to cancel semantic model operations.
+ ///
+ /// The parameter property path, leaf type name, and normalized expression text when a supported
+ /// lambda is found; otherwise, .
+ ///
internal static ParameterLambda? FindParameterLambda(
SeparatedSyntaxList args,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
for (var argumentIndex = WithParameterSearchStartIndex; argumentIndex < args.Count; argumentIndex++)
{
- var paramPath = SyntaxHelpers.ExtractPropertyPathFromLambda(args[argumentIndex].Expression, semanticModel, ct);
+ var paramPath = SyntaxHelpers.ExtractPropertyPathFromLambda(args[argumentIndex].Expression, semanticModel, scope, ct);
if (paramPath is not null)
{
return new ParameterLambda(
@@ -192,6 +208,7 @@ internal static bool IsToEventArgument(
/// The member access the invocation hangs off.
/// The invocation arguments.
/// The semantic model.
+ /// Where the generated code is declared, which decides the types it can name.
/// Cancellation token.
///
/// The fully qualified view and view model type names, or when either names a type
@@ -201,12 +218,12 @@ internal static bool IsToEventArgument(
MemberAccessExpressionSyntax memberAccess,
SeparatedSyntaxList args,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
var viewType = semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type;
- var viewTypeFullName = ExtractorValidation.GetDeclarableTypeDisplayName(viewType);
- var viewModelTypeFullName =
- ExtractorValidation.GetDeclarableTypeDisplayName(semanticModel.GetTypeInfo(args[0].Expression, ct).Type);
+ var viewTypeFullName = scope.NameOf(viewType);
+ var viewModelTypeFullName = scope.NameOf(semanticModel.GetTypeInfo(args[0].Expression, ct).Type);
return viewTypeFullName is null || viewModelTypeFullName is null
? null
@@ -273,10 +290,26 @@ internal static ControlBinding ResolveControlBinding(
/// The semantic model.
/// Cancellation token.
/// The detected parameter overload information.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ParameterOverloadInfo DetectParameterOverload(
+ IMethodSymbol methodSymbol,
+ SeparatedSyntaxList args,
+ SemanticModel semanticModel,
+ CancellationToken ct) =>
+ DetectParameterOverload(methodSymbol, args, semanticModel, ReachScope.Assembly(semanticModel.Compilation), ct);
+
+ /// Detects whether the method has a withParameter overload, reading its path in the given scope.
+ /// The resolved method symbol.
+ /// The invocation argument list.
+ /// The semantic model.
+ /// Where the generated code that reads the path is declared.
+ /// Cancellation token.
+ /// The detected parameter overload information.
internal static ParameterOverloadInfo DetectParameterOverload(
IMethodSymbol methodSymbol,
SeparatedSyntaxList args,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
var result = new ParameterOverloadInfo();
@@ -294,7 +327,7 @@ internal static ParameterOverloadInfo DetectParameterOverload(
result.HasExpressionParameter = true;
// Find the withParameter argument
- var paramResult = FindParameterLambda(args, semanticModel, ct);
+ var paramResult = FindParameterLambda(args, semanticModel, scope, ct);
if (paramResult is not null)
{
result.ParameterPropertyPath = new EquatableArray(paramResult.Value.PropertyPath);
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs
index 264f4e0f..41f8f0ff 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs
@@ -85,12 +85,65 @@ internal static bool HasItems(ImmutableArray items) =>
///
/// Generated overloads and interceptors are declared in a class of their own, so a private or protected
/// nested type - or a generic closed over one - is out of their reach, and so is a type parameter of the
- /// calling code, alone or as a type argument of another type. Naming one anyway fails the consumer's whole
- /// build over generated code they cannot edit.
+ /// calling code, alone or as a type argument of another type. An anonymous type has no name at all, and a
+ /// file-local type has none outside its own file. The compiler counts both as accessible, so they are tested for
+ /// separately. Naming one anyway fails the consumer's whole build over generated code they cannot edit.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsReachableFromGeneratedCode(ITypeSymbol? type, Compilation compilation) =>
- type is not null && !ContainsTypeParameter(type) && compilation.IsSymbolAccessibleWithin(type, compilation.Assembly);
+ type is not null
+ && !ContainsTypeParameter(type)
+ && !ContainsNamelessType(type)
+ && compilation.IsSymbolAccessibleWithin(type, compilation.Assembly);
+
+ /// Determines whether a type is, or is built from, a type generated code has no name for.
+ /// The type.
+ /// when an anonymous or file-local type appears anywhere in the type.
+ /// Generated code sits in files of its own, so a file-local type is as nameless to it as an anonymous one.
+ internal static bool ContainsNamelessType(ITypeSymbol type) =>
+ type switch
+ {
+ { IsAnonymousType: true } => true,
+ IArrayTypeSymbol array => ContainsNamelessType(array.ElementType),
+ INamedTypeSymbol named => named.IsFileLocal
+ || AnyContainsNamelessType(named.TypeArguments)
+ || (named.ContainingType is { } containing && ContainsNamelessType(containing)),
+ _ => false,
+ };
+
+ /// Finds the one type argument a generic interceptor has to take in place of naming it.
+ /// The resolved binding method.
+ /// The consumer compilation.
+ /// The position of the selector's result among the method's type arguments.
+ ///
+ /// when every type argument generated code cannot name is the selector's result, which an
+ /// interceptor can take as a type parameter. when another type argument is out of reach,
+ /// or the method has no selector whose result is one of its own type parameters.
+ ///
+ ///
+ /// Generated code only passes the result along: the user's selector builds it and the observation hands it on.
+ /// So an anonymous type, a private nested type, the caller's type parameter, or a type built from any of them can
+ /// stay a type parameter there. The compiler closes the interceptor over whatever the call closed the result over.
+ ///
+ internal static bool TryFindUnnameableResult(IMethodSymbol method, Compilation compilation, out int resultOrdinal)
+ {
+ resultOrdinal = FindSelectorResultOrdinal(method.OriginalDefinition);
+ if (resultOrdinal < 0 || method.ContainingType.IsGenericType)
+ {
+ return false;
+ }
+
+ var typeArguments = method.TypeArguments;
+ for (var i = 0; i < typeArguments.Length; i++)
+ {
+ if (i != resultOrdinal && !IsReachableFromGeneratedCode(typeArguments[i], compilation))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
/// Determines whether a type is, or is built from, a type parameter.
/// The type.
@@ -139,10 +192,10 @@ internal static bool NamesOnlyReachableTypes(IMethodSymbol method, Compilation c
/// The fully qualified type name, or when no overload could name it.
///
///
- /// 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.
+ /// 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 code that is not generic over it puts an identifier no consumer declared into their
+ /// build, so the whole compilation fails over generated code they cannot edit. ReachScope.NameOf
+ /// names one only where the generated code is generic over it.
///
///
/// A static type fails the same way and reaches here by a different route: a call written through the
@@ -217,6 +270,40 @@ internal static string ResolveEventArgsType(ITypeSymbol? delegateType)
private static bool IsExtensionGroupingType(INamedTypeSymbol type) =>
type.Name.Length == 0 || type.Name[0] == '<';
+ /// Finds which of a method's own type parameters its selector returns.
+ /// The method as declared, with its type parameters unsubstituted.
+ /// The type parameter's ordinal, or -1 when the method has no selector returning one.
+ private static int FindSelectorResultOrdinal(IMethodSymbol definition)
+ {
+ var parameters = definition.Parameters;
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ if (parameters[i] is { Name: "selector" or "conversionFunc", Type: INamedTypeSymbol { TypeArguments.Length: > 0 } func }
+ && func.TypeArguments[func.TypeArguments.Length - 1] is ITypeParameterSymbol { TypeParameterKind: TypeParameterKind.Method } result)
+ {
+ return result.Ordinal;
+ }
+ }
+
+ return -1;
+ }
+
+ /// Determines whether any of a list of types is, or is built from, a type generated code has no name for.
+ /// The types.
+ /// when an anonymous or file-local type appears in any of them.
+ private static bool AnyContainsNamelessType(ImmutableArray types)
+ {
+ for (var i = 0; i < types.Length; i++)
+ {
+ if (ContainsNamelessType(types[i]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/// Determines whether any of a list of types is, or is built from, a type parameter.
/// The types.
/// when a type parameter appears in any of them.
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/InteractionExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InteractionExtractor.cs
index 6bcef51e..367c3b0d 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/InteractionExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InteractionExtractor.cs
@@ -35,7 +35,7 @@ internal static class InteractionExtractor
// Verify this is our stub or generated method
if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !CallSiteHosting.TryResolve(context, methodSymbol, ct, out var scope))
{
return null;
}
@@ -45,7 +45,7 @@ internal static class InteractionExtractor
// Extract the interaction property path from the second argument (propertyName)
var propertyNameArg = args[1].Expression;
- var interactionPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(propertyNameArg, semanticModel, ct);
+ var interactionPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(propertyNameArg, semanticModel, scope, ct);
if (interactionPropertyPath is null)
{
return null;
@@ -62,7 +62,7 @@ internal static class InteractionExtractor
var isTaskHandler = DetermineHandlerVariant(methodSymbol, out var dontCareTypeFullName);
// Get types
- var viewTypeFullName = ResolveViewType(memberAccess, semanticModel, ct, out var viewClassInfo);
+ var viewTypeFullName = ResolveViewType(memberAccess, semanticModel, scope, ct, out var viewClassInfo);
if (viewTypeFullName is null)
{
return null;
@@ -89,7 +89,8 @@ internal static class InteractionExtractor
Constants.BindInteractionMethodName,
expressionText,
viewClassInfo,
- InterceptableLocationReader.Read(semanticModel, invocation, ct));
+ InterceptableLocationReader.Read(semanticModel, invocation, ct),
+ scope.Call);
}
/// Resolves the interaction's two type arguments, refusing a call site that names neither.
@@ -116,6 +117,7 @@ private static void ResolveValidatedInteractionTypes(
/// Names the view type the call was made on, and reads how it notifies from the same symbol.
/// The member access naming the view the call was made on.
/// The semantic model.
+ /// Where the generated code is declared, which decides the types it can name.
/// The cancellation token.
/// How the view notifies, or when no type was named.
/// The fully qualified view type name, or when the view names no type.
@@ -128,23 +130,34 @@ private static void ResolveValidatedInteractionTypes(
private static string? ResolveViewType(
MemberAccessExpressionSyntax memberAccess,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct,
out ClassBindingInfo? viewClassInfo)
{
viewClassInfo = null;
+ var viewType = semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type;
- // A type parameter names no type a generated overload could declare, so emitting one would put the
- // parameter's own name in the consumer's build. The call site is left to the runtime stub instead.
- if (semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type is not INamedTypeSymbol viewTypeSymbol)
+ // A view typed by the caller's type parameter only reaches here when the generated code is generic over it, and
+ // how it notifies is read from its constraint.
+ if (NotifyingType(viewType) is { } viewTypeSymbol)
{
- return null;
+ viewClassInfo = TypeDetectionExtractor.ExtractFromSymbol(viewTypeSymbol, semanticModel.Compilation, ct);
}
- viewClassInfo = TypeDetectionExtractor.ExtractFromSymbol(viewTypeSymbol, semanticModel.Compilation, ct);
-
- return ExtractorValidation.GetTypeDisplayName(viewTypeSymbol);
+ return scope.NameOf(viewType);
}
+ /// Reads the type whose notifications a view raises: the view's own, or its type parameter's first constraint.
+ /// The view's type.
+ /// The type, or null when there is none to read.
+ private static INamedTypeSymbol? NotifyingType(ITypeSymbol? viewType) =>
+ viewType switch
+ {
+ INamedTypeSymbol named => named,
+ ITypeParameterSymbol { ConstraintTypes: [INamedTypeSymbol first, ..] } => first,
+ _ => null,
+ };
+
///
/// Resolves the TInput and TOutput type arguments of the targeted
/// IInteraction<TInput, TOutput> member from the type of the lambda body.
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs
index 6e0fb47a..2d6b4548 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs
@@ -33,7 +33,7 @@ internal static class InvokeCommandExtractor
}
if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !CallSiteHosting.TryResolve(context, methodSymbol, ct, out var scope))
{
return null;
}
@@ -54,9 +54,9 @@ internal static class InvokeCommandExtractor
}
var commandArg = args[1].Expression;
- var commandPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(commandArg, semanticModel, ct);
+ var commandPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(commandArg, semanticModel, scope, ct);
var targetTypeName =
- ExtractorValidation.GetDeclarableTypeDisplayName(semanticModel.GetTypeInfo(args[0].Expression, ct).Type);
+ scope.NameOf(semanticModel.GetTypeInfo(args[0].Expression, ct).Type);
// A target the model cannot name leaves nothing to declare a member against, generated or otherwise.
if (targetTypeName is null)
@@ -75,6 +75,7 @@ internal static class InvokeCommandExtractor
targetTypeName,
new(commandPropertyPath),
commandArg.ToString(),
- InterceptableLocationReader.Read(semanticModel, invocation, ct));
+ InterceptableLocationReader.Read(semanticModel, invocation, ct),
+ scope.Call);
}
}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ObservationExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ObservationExtractor.cs
index 85a71f0b..13f73109 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ObservationExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ObservationExtractor.cs
@@ -80,7 +80,7 @@ internal static bool IsSelectorParameterName(string parameterName) =>
// Verify this is our stub or generated method
if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !TryResolveScope(context, methodSymbol, ct, out var scope, out var genericResult))
{
return null;
}
@@ -93,6 +93,7 @@ internal static bool IsSelectorParameterName(string parameterName) =>
methodSymbol,
args,
semanticModel,
+ scope,
propertyPaths,
expressionTexts,
ct);
@@ -104,13 +105,15 @@ internal static bool IsSelectorParameterName(string parameterName) =>
// Get the source type from the receiver
var sourceTypeFullName =
- ExtractorValidation.GetDeclarableTypeDisplayName(semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type);
+ scope.NameOf(semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type);
if (sourceTypeFullName is null)
{
return null;
}
- var returnTypeFullName = ComputeReturnTypeFullName(methodSymbol, propertyPaths, hasSelector);
+ var returnTypeFullName = genericResult.IsGeneric
+ ? GenericResult.TypeParameterName
+ : ComputeReturnTypeFullName(methodSymbol, propertyPaths, hasSelector);
var filePath = invocation.SyntaxTree.FilePath;
var lineNumber = SyntaxHelpers.CallerLineNumber(invocation, ct);
@@ -125,7 +128,69 @@ internal static bool IsSelectorParameterName(string parameterName) =>
hasSelector,
expectedMethodName,
new([.. expressionTexts]),
- InterceptableLocationReader.Read(semanticModel, invocation, ct));
+ InterceptableLocationReader.Read(semanticModel, invocation, ct),
+ genericResult,
+ scope.Call);
+ }
+
+ /// Decides where an observation call's generated code is declared, and whether its result is a type parameter.
+ /// The call site and its semantic model.
+ /// The resolved binding method.
+ /// Cancellation token.
+ /// The scope the generated code can name types in, which may move while the call is read.
+ /// Where the result sits when generated code takes it as a type parameter.
+ /// when the call can be generated; otherwise .
+ ///
+ /// A result generated code cannot name is taken as a type parameter first, because that needs nothing of the
+ /// caller, and the code then stays where it is. Any other call moves only when its types or paths need it.
+ ///
+ internal static bool TryResolveScope(
+ in CallSiteContext context,
+ IMethodSymbol method,
+ CancellationToken ct,
+ out ReachScope scope,
+ out GenericResult genericResult)
+ {
+ scope = ReachScope.ForCall(context, method, ct);
+ if (!TryReadGenericResult(method, context.SemanticModel.Compilation, out genericResult))
+ {
+ return scope.CanNameAll(method.TypeArguments);
+ }
+
+ if (genericResult.IsGeneric)
+ {
+ scope.Pin();
+ }
+
+ return true;
+ }
+
+ /// Decides whether generated code can write a call's types, taking its selector's result as a type parameter if need be.
+ /// The resolved binding method.
+ /// The consumer compilation.
+ /// Where the result sits when generated code takes it as a type parameter.
+ /// when the call can be generated; otherwise .
+ ///
+ /// Only an interceptor can be generic over the result, because it takes the called method's own type
+ /// parameters. A build that cannot describe a call site has no interceptor to write, so such a call stays on
+ /// the diagnostic path there.
+ ///
+ internal static bool TryReadGenericResult(IMethodSymbol method, Compilation compilation, out GenericResult genericResult)
+ {
+ genericResult = default;
+ if (ExtractorValidation.NamesOnlyReachableTypes(method, compilation))
+ {
+ return true;
+ }
+
+ if (!InterceptableLocationReader.IsSupported
+ || !ExtractorValidation.TryFindUnnameableResult(method, compilation, out var ordinal))
+ {
+ return false;
+ }
+
+ genericResult = new(method.TypeArguments.Length, ordinal);
+ return true;
}
///
@@ -135,6 +200,7 @@ internal static bool IsSelectorParameterName(string parameterName) =>
/// The resolved method symbol.
/// The invocation argument list.
/// The semantic model.
+ /// Where the generated code that reads the paths is declared.
/// The list to append extracted property paths to.
/// The list to append normalized expression texts to.
/// Cancellation token.
@@ -146,6 +212,7 @@ private static bool CollectPropertyPaths(
IMethodSymbol methodSymbol,
SeparatedSyntaxList args,
SemanticModel semanticModel,
+ ReachScope scope,
List> propertyPaths,
List expressionTexts,
CancellationToken ct)
@@ -170,7 +237,7 @@ private static bool CollectPropertyPaths(
{
// One path generated code cannot read leaves the whole call to the runtime stub. Keeping the
// others would generate a method with fewer parameters than the call, which an interceptor cannot claim.
- var path = SyntaxHelpers.ExtractPropertyPathFromLambda(args[i].Expression, semanticModel, ct);
+ var path = SyntaxHelpers.ExtractPropertyPathFromLambda(args[i].Expression, semanticModel, scope, ct);
if (path is null)
{
propertyPaths.Clear();
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ReachScope.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ReachScope.cs
new file mode 100644
index 00000000..5acc8d86
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ReachScope.cs
@@ -0,0 +1,189 @@
+// 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 System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using ReactiveUI.Binding.SourceGenerators.Models;
+
+namespace ReactiveUI.Binding.SourceGenerators.Helpers;
+
+/// Where a call site's generated code is declared, which decides the types and members it can name.
+///
+///
+/// Most generated code sits in a class of its own in the consumer's assembly. On a build that intercepts, a call that
+/// needs more moves, the first time it needs it:
+///
+///
+/// into a generic class whose type parameters stand in for the caller's, when the call is built from them;
+/// into the caller's own partial class, when the call names a private or protected type or member.
+///
+///
+/// A move happens while the call's types and paths are read, so a call that needs neither costs nothing more. Never part
+/// of a pipeline's output.
+///
+///
+internal sealed class ReachScope
+{
+ /// The step a scope that cannot move any further is at.
+ private const int LastStep = 2;
+
+ /// The call site and its semantic model, for a scope that can move.
+ private readonly CallSiteContext _context;
+
+ /// The resolved binding method, or null for a scope that never moves.
+ private readonly IMethodSymbol? _method;
+
+ /// Cancels the reads a move makes.
+ private readonly CancellationToken _cancellationToken;
+
+ /// How far the scope has moved: 0 not at all, 1 into a generic class, 2 into the caller's class.
+ private int _step;
+
+ /// Initializes a new instance of the class.
+ /// The consumer compilation.
+ /// The call site and its semantic model.
+ /// The resolved binding method, or null for a scope that never moves.
+ /// Cancels the reads a move makes.
+ private ReachScope(Compilation compilation, in CallSiteContext context, IMethodSymbol? method, CancellationToken cancellationToken)
+ {
+ Compilation = compilation;
+ _context = context;
+ _method = method;
+ _cancellationToken = cancellationToken;
+ _step = method is not null && InterceptableLocationReader.IsSupported ? 0 : LastStep;
+ }
+
+ /// Gets the consumer compilation.
+ internal Compilation Compilation { get; }
+
+ /// Gets the caller's class the generated code is declared in, or null when it sits in a class of its own.
+ internal INamedTypeSymbol? Host { get; private set; }
+
+ /// Gets the caller's type parameters the generated code is generic over.
+ internal ImmutableArray TypeParameters { get; private set; } = ImmutableArray.Empty;
+
+ /// Gets the call, described for the interceptor that claims it from the moved code, or null when it did not move.
+ internal HostedCall? Call { get; private set; }
+
+ /// Creates a scope that never moves, for code declared in a class of its own.
+ /// The consumer compilation.
+ /// The scope.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ReachScope Assembly(Compilation compilation) => new(compilation, default, null, default);
+
+ /// Creates a scope for one call site, which may move when the call needs it.
+ /// The call site and its semantic model.
+ /// The resolved binding method.
+ /// Cancels the reads a move makes.
+ /// The scope.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ReachScope ForCall(in CallSiteContext context, IMethodSymbol method, CancellationToken cancellationToken) =>
+ new(context.SemanticModel.Compilation, context, method, cancellationToken);
+
+ /// Stops the scope from moving, for a call whose generated code has to stay in a class of its own.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal void Pin() => _step = LastStep;
+
+ /// Determines whether code in this scope can name a type, moving the scope if that makes it possible.
+ /// The type, which may be null.
+ /// when the type is closed over names in scope, has a name, and is accessible here.
+ internal bool CanName(ITypeSymbol? type)
+ {
+ while (!CanNameHere(type))
+ {
+ if (!Move())
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// Determines whether code in this scope can name every type in a list, moving the scope if need be.
+ /// The types.
+ /// when every type can be named here.
+ internal bool CanNameAll(ImmutableArray types)
+ {
+ for (var i = 0; i < types.Length; i++)
+ {
+ if (!CanName(types[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// Names a type a generated member declares a parameter of, when code in this scope can.
+ /// The type, which may be null.
+ /// The fully qualified name, or null when no generated member could declare a parameter of it.
+ ///
+ /// The calling code's type parameter is named only when the generated code is generic over it, moving the scope there
+ /// if it can.
+ ///
+ internal string? NameOf(ITypeSymbol? type)
+ {
+ if (type is not ITypeParameterSymbol)
+ {
+ return ExtractorValidation.GetDeclarableTypeDisplayName(type);
+ }
+
+ return CanName(type) ? ExtractorValidation.GetTypeDisplayName(type) : null;
+ }
+
+ /// Determines whether code in this scope can read a member, moving the scope into the caller's class if need be.
+ /// The property or field.
+ /// The type the member is read through.
+ /// when the member is visible to the assembly, or to the caller's class.
+ internal bool CanRead(ISymbol member, ITypeSymbol owner)
+ {
+ if (member.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal)
+ {
+ return true;
+ }
+
+ while (Host is null)
+ {
+ if (!Move())
+ {
+ return false;
+ }
+ }
+
+ return Compilation.IsSymbolAccessibleWithin(member, Host, owner);
+ }
+
+ /// Takes the next step out of a class of its own, when the call allows it.
+ /// when the scope moved.
+ private bool Move()
+ {
+ while (_step < LastStep)
+ {
+ _step++;
+ if (CallSiteHosting.TryMove(_context, _method!, _step == LastStep, _cancellationToken) is not { } moved)
+ {
+ continue;
+ }
+
+ Host = moved.Host;
+ TypeParameters = moved.TypeParameters;
+ Call = moved.Call;
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Determines whether code in this scope, as it stands, can name a type.
+ /// The type, which may be null.
+ /// when it can.
+ private bool CanNameHere(ITypeSymbol? type) =>
+ type is not null
+ && !ExtractorValidation.ContainsNamelessType(type)
+ && CallSiteHosting.UsesOnly(type, TypeParameters)
+ && Compilation.IsSymbolAccessibleWithin(type, (ISymbol?)Host ?? Compilation.Assembly);
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs
index b835c8c1..1c05cb8a 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs
@@ -34,15 +34,30 @@ internal static int CallerLineNumber(InvocationExpressionSyntax invocation, Canc
return invocation.SyntaxTree.GetLineSpan(anchor, ct).StartLinePosition.Line + 1;
}
+ /// Extracts the property path from a lambda expression, for code declared in a class of its own.
+ /// The expression syntax.
+ /// The semantic model.
+ /// Cancellation token.
+ /// An array of property path segments, or null if the expression is not a valid lambda.
+ /// If the cancellation token is triggered.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static PropertyPathSegment[]? ExtractPropertyPathFromLambda(
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ CancellationToken ct) =>
+ ExtractPropertyPathFromLambda(expression, semanticModel, ReachScope.Assembly(semanticModel.Compilation), ct);
+
/// Extracts the property path from a lambda expression.
/// The expression syntax.
/// The semantic model.
+ /// Where the generated code that reads the path is declared.
/// Cancellation token.
/// An array of property path segments, or null if the expression is not a valid lambda.
/// If the cancellation token is triggered.
internal static PropertyPathSegment[]? ExtractPropertyPathFromLambda(
ExpressionSyntax expression,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
// Must be an inline lambda
@@ -70,7 +85,7 @@ internal static int CallerLineNumber(InvocationExpressionSyntax invocation, Canc
{
ct.ThrowIfCancellationRequested();
- if (ReadPathSegment(memberAccess, semanticModel, segments.Count == 0, ct) is not { } segment)
+ if (ReadPathSegment(memberAccess, semanticModel, scope, segments.Count == 0, ct) is not { } segment)
{
return null;
}
@@ -131,12 +146,14 @@ PostfixUnaryExpressionSyntax postfix when postfix.IsKind(SyntaxKind.SuppressNull
/// Reads one link of an observed property path.
/// The member access naming the link.
/// The semantic model.
+ /// Where the generated code that reads the link is declared.
/// Whether the link is the last of the path.
/// Cancellation token.
/// The segment, or null when the link is not a property or field generated code can read.
private static PropertyPathSegment? ReadPathSegment(
MemberAccessExpressionSyntax memberAccess,
SemanticModel semanticModel,
+ ReachScope scope,
bool isLeaf,
CancellationToken ct)
{
@@ -148,18 +165,23 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type,
_ => null,
};
- // Private and protected members are out of reach of generated code.
- if (memberType is null || member!.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal))
+ if (memberType is null)
{
return null;
}
- var owner = semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type as INamedTypeSymbol ?? member.ContainingType;
+ var link = member!;
+ var owner = semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type as INamedTypeSymbol ?? link.ContainingType;
+
+ // A private or protected member is only in reach of code in the caller's own partial class.
+ if (!scope.CanRead(link, owner))
+ {
+ return null;
+ }
// Generated code names every link's owner and value type, so a link through a type it cannot reach
// leaves the whole path to the runtime stub.
- if (!ExtractorValidation.IsReachableFromGeneratedCode(owner, semanticModel.Compilation)
- || !ExtractorValidation.IsReachableFromGeneratedCode(memberType, semanticModel.Compilation))
+ if (!scope.CanName(owner) || !scope.CanName(memberType))
{
return null;
}
@@ -167,7 +189,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type,
// A field raises no notification, so it resolves no mechanism and is read once, like any other link whose
// owner cannot notify. It still carries its owner, which is how a call site whose first path starts at a
// field learns how the observed type notifies, and which view model property a view binding follows.
- return member is IPropertySymbol propertySymbol
+ return link is IPropertySymbol propertySymbol
? new(
propertySymbol.Name,
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
@@ -175,7 +197,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type,
memberType.IsReferenceType,
TypeDetectionExtractor.ExtractPropertyOwner(owner, propertySymbol, semanticModel.Compilation, ct))
: new(
- member.Name,
+ link.Name,
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
owner.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
memberType.IsReferenceType,
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ToPropertyExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ToPropertyExtractor.cs
index 42aba3ef..d2c54068 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ToPropertyExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ToPropertyExtractor.cs
@@ -60,7 +60,7 @@ internal static class ToPropertyExtractor
var method = ExtractorValidation.ExtractMethodSymbol(semanticModel.GetSymbolInfo(invocation, ct));
if (method is not { TypeArguments.Length: StubTypeArgumentCount }
|| !ExtractorValidation.IsRecognizedExtensionClass(method.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(method, semanticModel.Compilation))
+ || !CallSiteHosting.TryResolve(context, method, ct, out var scope))
{
return null;
}
@@ -83,7 +83,8 @@ internal static class ToPropertyExtractor
propertyArgument.ToString(),
shape,
resolved.Raise,
- InterceptableLocationReader.Read(semanticModel, invocation, ct));
+ InterceptableLocationReader.Read(semanticModel, invocation, ct),
+ scope.Call);
}
/// Names the stub's type arguments and chooses how the source type's notifications are raised.
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/WhenAnyObservableExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/WhenAnyObservableExtractor.cs
index 02ed6dbf..abe4dd7a 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/WhenAnyObservableExtractor.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/WhenAnyObservableExtractor.cs
@@ -36,13 +36,13 @@ internal static class WhenAnyObservableExtractor
// Verify this is our stub or generated method
if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)
- || !ExtractorValidation.NamesOnlyReachableTypes(methodSymbol, semanticModel.Compilation))
+ || !ObservationExtractor.TryResolveScope(context, methodSymbol, ct, out var scope, out var genericResult))
{
return null;
}
var (propertyPaths, expressionTexts, innerObservableTypes, hasSelector) =
- CollectObservableArguments(methodSymbol, invocation.ArgumentList.Arguments, semanticModel, ct);
+ CollectObservableArguments(methodSymbol, invocation.ArgumentList.Arguments, semanticModel, scope, ct);
if (propertyPaths.Count == 0)
{
@@ -51,20 +51,21 @@ internal static class WhenAnyObservableExtractor
// Get the source type from the receiver
var sourceTypeFullName =
- ExtractorValidation.GetDeclarableTypeDisplayName(semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type);
+ scope.NameOf(semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type);
if (sourceTypeFullName is null)
{
return null;
}
// Compute return type
- var returnTypeFullName = hasSelector
+ var namedReturnTypeFullName = hasSelector
? ExtractorValidation.FindSelectorReturnType(
methodSymbol.Parameters,
"selector")!
: InvalidOperationExceptionHelper.EnsureNotNull(
innerObservableTypes[0],
"inner observable types");
+ var returnTypeFullName = genericResult.IsGeneric ? GenericResult.TypeParameterName : namedReturnTypeFullName;
var filePath = invocation.SyntaxTree.FilePath;
var lineNumber = SyntaxHelpers.CallerLineNumber(invocation, ct);
@@ -78,7 +79,9 @@ internal static class WhenAnyObservableExtractor
returnTypeFullName,
hasSelector,
new([.. expressionTexts]),
- InterceptableLocationReader.Read(semanticModel, invocation, ct));
+ InterceptableLocationReader.Read(semanticModel, invocation, ct),
+ genericResult,
+ scope.Call);
}
///
@@ -88,12 +91,14 @@ internal static class WhenAnyObservableExtractor
/// The resolved method.
/// The invocation arguments.
/// The semantic model.
+ /// Where the generated code that reads the paths is declared.
/// Cancellation token.
/// The observed paths, their expression texts, their inner types, and whether a selector was supplied.
private static ObservableArguments CollectObservableArguments(
IMethodSymbol methodSymbol,
SeparatedSyntaxList args,
SemanticModel semanticModel,
+ ReachScope scope,
CancellationToken ct)
{
var propertyPaths = new List>(args.Count);
@@ -118,7 +123,7 @@ private static ObservableArguments CollectObservableArguments(
// One path generated code cannot read leaves the whole call to the runtime stub. Keeping the others
// would generate a method with fewer parameters than the call, which an interceptor cannot claim.
- var path = SyntaxHelpers.ExtractPropertyPathFromLambda(args[i].Expression, semanticModel, ct);
+ var path = SyntaxHelpers.ExtractPropertyPathFromLambda(args[i].Expression, semanticModel, scope, ct);
if (path is null)
{
propertyPaths.Clear();
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvocationPipeline.cs b/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvocationPipeline.cs
index c745e0b8..68a3c619 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvocationPipeline.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvocationPipeline.cs
@@ -30,6 +30,7 @@ internal static void Register(
IncrementalValueProvider languageFeatures,
string hintName,
Func, ImmutableArray, LanguageFeatures, string?> emit)
+ where TInvocation : IClaimableCallSite
{
var combined = invocations.Collect().Combine(languageFeatures);
@@ -37,7 +38,10 @@ internal static void Register(
combined,
(ctx, data) =>
{
- var source = emit(data.Left, ImmutableArray.Empty, data.Right);
+ var source = HostedCallSiteEmitter.Compose(
+ data.Left,
+ data.Right,
+ (calls, features) => emit(calls, ImmutableArray.Empty, features));
if (source is null)
{
return;
@@ -64,6 +68,7 @@ internal static void Register(
IncrementalValueProvider languageFeatures,
string hintName,
Func, LanguageFeatures, string?> emit)
+ where TInvocation : IClaimableCallSite
{
var combined = invocations.Collect().Combine(languageFeatures);
@@ -71,7 +76,7 @@ internal static void Register(
combined,
(ctx, data) =>
{
- var source = emit(data.Left, data.Right);
+ var source = HostedCallSiteEmitter.Compose(data.Left, data.Right, emit);
if (source is null)
{
return;
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/BindCommandInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/BindCommandInvocationInfo.cs
index 18ecca8f..39e491be 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/BindCommandInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/BindCommandInvocationInfo.cs
@@ -35,6 +35,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
/// Where this call site is, for a build that claims call sites outright rather than competing for them.
///
/// The invoker class a write to the view carries, or null for none.
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record BindCommandInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -59,7 +63,8 @@ internal sealed record BindCommandInvocationInfo(
bool HasCommandParameterProperty,
bool HasEnabledProperty,
InterceptorLocation Interceptor = default,
- string? ViewThreadInvoker = null)
+ string? ViewThreadInvoker = null,
+ HostedCall? Host = null) : IClaimableCallSite
{
/// Gets a value indicating whether the caller explicitly selected an event.
public bool HasExplicitEvent { get; init; }
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/BindInteractionInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/BindInteractionInvocationInfo.cs
index f20fac3a..7328ba43 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/BindInteractionInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/BindInteractionInvocationInfo.cs
@@ -29,6 +29,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
///
/// Where this call site is, for a build that claims call sites outright rather than competing for them.
///
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record BindInteractionInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -42,4 +46,5 @@ internal sealed record BindInteractionInvocationInfo(
string MethodName,
string ExpressionText,
ClassBindingInfo? ViewClassInfo = null,
- InterceptorLocation Interceptor = default);
+ InterceptorLocation Interceptor = default,
+ HostedCall? Host = null) : IClaimableCallSite;
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/BindToInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/BindToInvocationInfo.cs
index 9a9bb679..37c48e04 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/BindToInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/BindToInvocationInfo.cs
@@ -17,6 +17,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
/// The original expression text of the target lambda argument.
/// The call site's location, for a build that intercepts call sites.
/// The invoker class a write to the target carries, or null for none.
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record BindToInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -29,7 +33,8 @@ internal sealed record BindToInvocationInfo(
bool HasConverterOverride,
string TargetExpressionText,
InterceptorLocation Interceptor = default,
- string? TargetViewThreadInvoker = null)
+ string? TargetViewThreadInvoker = null,
+ HostedCall? Host = null) : IClaimableCallSite
{
/// Gets the typed mechanism converting stream values to the target.
public ConversionInfo? Conversion { get; init; }
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/BindingInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/BindingInvocationInfo.cs
index 7a6ac2ab..c07e1b32 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/BindingInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/BindingInvocationInfo.cs
@@ -29,6 +29,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
///
/// The invoker class a write to the source carries, or null for none.
/// The invoker class a write to the target carries, or null for none.
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record BindingInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -47,7 +51,8 @@ internal sealed record BindingInvocationInfo(
bool HasConverterOverride,
InterceptorLocation Interceptor = default,
string? SourceViewThreadInvoker = null,
- string? TargetViewThreadInvoker = null)
+ string? TargetViewThreadInvoker = null,
+ HostedCall? Host = null) : IClaimableCallSite
{
/// Gets the typed mechanism converting source values to the target.
public ConversionInfo? ForwardConversion { get; init; }
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/GenericResult.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/GenericResult.cs
new file mode 100644
index 00000000..e1e54c73
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/GenericResult.cs
@@ -0,0 +1,23 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Models;
+
+/// Where a call's selector result sits when generated code takes it as a type parameter instead of naming it.
+/// How many type arguments the called method takes, or zero when every type is named.
+/// The position of the selector's result among those type arguments.
+///
+/// A selector can return an anonymous type, a private nested type, or a type built from one. Generated code cannot
+/// write any of those. It only passes the result along, so the observation method is generic over it, and so is the
+/// interceptor that claims the call. An interceptor's type parameters have to line up with the called method's, which
+/// is why the arity and the position travel with the call site.
+///
+internal readonly record struct GenericResult(int Arity, int Ordinal)
+{
+ /// The name generated code gives the result's type parameter.
+ internal const string TypeParameterName = "__TResult";
+
+ /// Gets a value indicating whether generated code takes the result as a type parameter.
+ internal bool IsGeneric => Arity > 0;
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/HostedCall.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/HostedCall.cs
new file mode 100644
index 00000000..313cd75f
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/HostedCall.cs
@@ -0,0 +1,53 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Models;
+
+/// A call site whose generated code moves out of a class of its own, and the signature of the method it calls.
+///
+/// The fully qualified name of the caller's class the generated code moves into, or null when it stays in the generated
+/// class and only becomes generic.
+///
+/// The partial declarations that reach the caller's class, outermost first, or null.
+/// The interceptor's type parameters, one for each of the called method's, in order.
+/// The interceptor's constraint clauses, each starting with where.
+/// The called method's return type, written with the interceptor's type parameters. Every binding API returns a value.
+/// The return type this call closes it to.
+/// The called method's parameters, the receiver first.
+///
+///
+/// A private or protected nested type or member can only be named inside the class that declares it. When that class
+/// is partial, the generator adds the call's generated code to it. A call built from the caller's type parameters gets
+/// its code wrapped in a class generic over them. Either way the interceptor still has to sit in the generated
+/// namespace, so it is generic: it repeats the called method's signature with its own type parameters, and forwards to
+/// an entry point beside the moved code, which casts to the closed types the call was made with.
+///
+///
+/// Strings only, so the model compares by value and carries no symbol.
+///
+///
+internal sealed record HostedCall(
+ string? HostTypeFullName,
+ PartialTypeDeclaration? Declaration,
+ EquatableArray TypeParameters,
+ EquatableArray Constraints,
+ string DeclaredReturnType,
+ string ClosedReturnType,
+ EquatableArray Parameters)
+{
+ /// Gets the caller's type parameters the moved code is generic over, by their own names.
+ internal EquatableArray WrapperTypeParameters { get; init; } = new([]);
+
+ /// Gets the constraint clauses of the caller's type parameters, as the caller declares them.
+ internal EquatableArray WrapperConstraints { get; init; } = new([]);
+
+ /// Gets the interceptor type parameter that hands on each of the caller's, in the same order.
+ internal EquatableArray WrapperTypeArguments { get; init; } = new([]);
+
+ /// Gets a value indicating whether the return type has to be cast from the closed type to the declared one.
+ internal bool ReturnNeedsCast => DeclaredReturnType != ClosedReturnType;
+
+ /// Gets a value indicating whether the moved code is generic over the caller's type parameters.
+ internal bool IsGeneric => WrapperTypeParameters.Length > 0;
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/HostedParameter.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/HostedParameter.cs
new file mode 100644
index 00000000..1e757a06
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/HostedParameter.cs
@@ -0,0 +1,15 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Models;
+
+/// One parameter of a method a hosted call site calls.
+/// The parameter's name.
+/// The parameter's type, written with the method's type parameters.
+/// The type the call closes it to, which may name a private nested type.
+internal readonly record struct HostedParameter(string Name, string DeclaredType, string ClosedType)
+{
+ /// Gets a value indicating whether the argument has to be cast from the declared type to the closed one.
+ internal bool NeedsCast => DeclaredType != ClosedType;
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/IClaimableCallSite.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/IClaimableCallSite.cs
new file mode 100644
index 00000000..4f14c66c
--- /dev/null
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/IClaimableCallSite.cs
@@ -0,0 +1,15 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Models;
+
+/// What every API's per-call-site model tells the pipeline about how the call is claimed.
+internal interface IClaimableCallSite
+{
+ /// Gets where the call site is, for a build that claims call sites with interceptors.
+ InterceptorLocation Interceptor { get; }
+
+ /// Gets the class the call's generated code is declared in, when only that class can name its types.
+ HostedCall? Host { get; }
+}
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/InvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/InvocationInfo.cs
index 3057c893..22fc6bcb 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/InvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/InvocationInfo.cs
@@ -23,6 +23,14 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
/// Reports that it describes nothing on a compiler that cannot describe one, and for a call the compiler
/// refuses to let anything intercept.
///
+///
+/// Where the selector's result sits when generated code cannot name it, so the observation and its interceptor are
+/// generic over it. Not generic when every type is named.
+///
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record InvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -33,4 +41,6 @@ internal sealed record InvocationInfo(
bool HasSelector,
string MethodName,
EquatableArray ExpressionTexts,
- InterceptorLocation Interceptor = default);
+ InterceptorLocation Interceptor = default,
+ GenericResult GenericResult = default,
+ HostedCall? Host = null) : IClaimableCallSite;
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs
index 81a78595..6e7f5391 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs
@@ -12,6 +12,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
/// The property path chain reaching the command.
/// The original expression text of the command lambda argument.
/// The call site's location, for a build that intercepts call sites.
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record InvokeCommandInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -19,4 +23,5 @@ internal sealed record InvokeCommandInvocationInfo(
string TargetTypeFullName,
EquatableArray CommandPropertyPath,
string CommandExpressionText,
- InterceptorLocation Interceptor = default);
+ InterceptorLocation Interceptor = default,
+ HostedCall? Host = null) : IClaimableCallSite;
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/ToPropertyInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/ToPropertyInvocationInfo.cs
index 1c236e47..0999e903 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/ToPropertyInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/ToPropertyInvocationInfo.cs
@@ -15,6 +15,10 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
/// The stub overload the call site resolved to.
/// How generated code raises the source type's change notifications.
/// The call site's location, for a build that intercepts call sites.
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record ToPropertyInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -25,4 +29,5 @@ internal sealed record ToPropertyInvocationInfo(
string PropertyExpressionText,
ToPropertyOverloadShape Shape,
PropertyRaiseInfo Raise,
- InterceptorLocation Interceptor = default);
+ InterceptorLocation Interceptor = default,
+ HostedCall? Host = null) : IClaimableCallSite;
diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/WhenAnyObservableInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/WhenAnyObservableInvocationInfo.cs
index fffab930..a13702c4 100644
--- a/src/ReactiveUI.Binding.SourceGenerators/Models/WhenAnyObservableInvocationInfo.cs
+++ b/src/ReactiveUI.Binding.SourceGenerators/Models/WhenAnyObservableInvocationInfo.cs
@@ -20,6 +20,14 @@ namespace ReactiveUI.Binding.SourceGenerators.Models;
///
/// Where this call site is, for a build that claims call sites outright rather than competing for them.
///
+///
+/// Where the selector's result sits when generated code cannot name it, so the observation and its interceptor are
+/// generic over it. Not generic when every type is named.
+///
+///
+/// The class the call's generated code is declared in, when only that class can name the call's types. Null when
+/// generated code can name them from its own class.
+///
internal sealed record WhenAnyObservableInvocationInfo(
string CallerFilePath,
int CallerLineNumber,
@@ -29,4 +37,6 @@ internal sealed record WhenAnyObservableInvocationInfo(
string ReturnTypeFullName,
bool HasSelector,
EquatableArray ExpressionTexts,
- InterceptorLocation Interceptor = default);
+ InterceptorLocation Interceptor = default,
+ GenericResult GenericResult = default,
+ HostedCall? Host = null) : IClaimableCallSite;
diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/DiagnosticSeverityTests.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/DiagnosticSeverityTests.cs
new file mode 100644
index 00000000..107f08c3
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/DiagnosticSeverityTests.cs
@@ -0,0 +1,62 @@
+// 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.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using ReactiveUI.Binding.Analyzer.Analyzers;
+
+namespace ReactiveUI.Binding.Analyzer.Tests;
+
+///
+/// Every diagnostic that reports a call no generated code claims, so that the call throws when it runs, fails the build.
+/// The rest describe a call that still runs, and stay warnings or information.
+///
+public class DiagnosticSeverityTests
+{
+ /// Verifies that a diagnostic about a call that would throw is an error.
+ /// The diagnostic id.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [Arguments("RXUIBIND001")]
+ [Arguments("RXUIBIND003")]
+ [Arguments("RXUIBIND006")]
+ [Arguments("RXUIBIND009")]
+ [Arguments("RXUIBIND012")]
+ [Arguments("RXUIBIND013")]
+ [Arguments("RXUIBIND014")]
+ [Arguments("RXUIBIND015")]
+ [Arguments("RXUIBIND016")]
+ [Arguments("RXUIBIND021")]
+ public async Task CallThatWouldThrow_IsAnError(string id) =>
+ await Assert.That(Descriptor(id).DefaultSeverity).IsEqualTo(DiagnosticSeverity.Error);
+
+ /// Verifies that a diagnostic about a call that still runs is not an error.
+ /// The diagnostic id.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [Arguments("RXUIBIND002")]
+ [Arguments("RXUIBIND004")]
+ [Arguments("RXUIBIND005")]
+ [Arguments("RXUIBIND007")]
+ [Arguments("RXUIBIND008")]
+ [Arguments("RXUIBIND010")]
+ [Arguments("RXUIBIND011")]
+ [Arguments("RXUIBIND017")]
+ [Arguments("RXUIBIND018")]
+ [Arguments("RXUIBIND019")]
+ [Arguments("RXUIBIND020")]
+ public async Task CallThatRuns_IsNotAnError(string id) =>
+ await Assert.That(Descriptor(id).DefaultSeverity).IsNotEqualTo(DiagnosticSeverity.Error);
+
+ /// Finds a descriptor among every analyzer the assembly ships.
+ /// The diagnostic id.
+ /// The descriptor.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static DiagnosticDescriptor Descriptor(string id) =>
+ typeof(BindingInvocationAnalyzer).Assembly.GetTypes()
+ .Where(static t => typeof(DiagnosticAnalyzer).IsAssignableFrom(t) && !t.IsAbstract)
+ .SelectMany(static t => ((DiagnosticAnalyzer)Activator.CreateInstance(t)!).SupportedDiagnostics)
+ .First(d => d.Id == id);
+}
diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/ToPropertyAnalyzerTests.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/ToPropertyAnalyzerTests.cs
index 6ff13430..71f27ea9 100644
--- a/src/tests/ReactiveUI.Binding.Analyzer.Tests/ToPropertyAnalyzerTests.cs
+++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/ToPropertyAnalyzerTests.cs
@@ -53,6 +53,18 @@ public static ObservableAsPropertyHelper ToProperty(
""";
+ /// A parenthesized selector that reads one member off its parameter names the property directly.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task IsDirectMemberSelector_ParenthesizedLambda_ReturnsTrue() =>
+ await Assert.That(ToPropertyAnalyzer.IsDirectMemberSelector(Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression("(x) => x.Name"))).IsTrue();
+
+ /// A selector held in a variable names no property the generator can read.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task IsDirectMemberSelector_NotALambda_ReturnsFalse() =>
+ await Assert.That(ToPropertyAnalyzer.IsDirectMemberSelector(Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression("selector"))).IsFalse();
+
/// A type with only a protected raise method and no partial declaration cannot be raised from generated code.
/// A task representing the asynchronous test operation.
[Test]
diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/UnreachableTypeAnalyzerTests.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/UnreachableTypeAnalyzerTests.cs
index 3452eb6f..4ab48b25 100644
--- a/src/tests/ReactiveUI.Binding.Analyzer.Tests/UnreachableTypeAnalyzerTests.cs
+++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/UnreachableTypeAnalyzerTests.cs
@@ -31,6 +31,12 @@ public static IObservable WhenChanged(
where TObj : class
=> throw new InvalidOperationException();
+ public static IObservable WhenAnyValue(
+ this TSender sender,
+ Expression> property1,
+ Func selector)
+ => throw new InvalidOperationException();
+
public static IObservable WhenAnyDynamic(
this TSender sender,
Expression property,
@@ -73,6 +79,109 @@ private sealed class Vm : INotifyPropertyChanged
await Assert.That(diagnostics[0].GetMessage()).Contains("Outer.Vm");
}
+ /// A call that no interceptor claims fails the build, so the diagnostic is an error.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task PrivateNestedReceiver_ReportedAsError()
+ {
+ const string Source = Preamble + """
+ namespace TestApp
+ {
+ public static class Outer
+ {
+ public static void Run() => new Vm().WhenChanged(x => x.Name);
+
+ private sealed class Vm : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+ public string Name { get; set; }
+ }
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(Source);
+ await Assert.That(diagnostics.Length).IsEqualTo(1);
+ await Assert.That(diagnostics[0].Severity).IsEqualTo(Microsoft.CodeAnalysis.DiagnosticSeverity.Error);
+ }
+
+ /// An anonymous selector result has no name generated code could write, so the call is reported.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task AnonymousSelectorResult_Reported()
+ {
+ const string Source = Preamble + """
+ namespace TestApp
+ {
+ public sealed class Vm : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+ public int A { get; set; }
+ }
+
+ public static class Outer
+ {
+ public static void Run() => new Vm().WhenAnyValue(x => x.A, a => new { a });
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(Source);
+ await Assert.That(diagnostics.Length).IsEqualTo(1);
+ await Assert.That(diagnostics[0].GetMessage()).Contains("anonymous");
+ }
+
+ /// A file-local type has no name outside its own file, where generated code never sits, so the call is reported.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task FileLocalReceiver_Reported()
+ {
+ const string Source = Preamble + """
+ namespace TestApp
+ {
+ public static class Outer
+ {
+ public static void Run() => new Vm().WhenChanged(x => x.Name);
+ }
+
+ file sealed class Vm : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+ public string Name { get; set; }
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(Source);
+ await Assert.That(diagnostics.Length).IsEqualTo(1);
+ await Assert.That(diagnostics[0].GetMessage()).Contains("file-local");
+ }
+
+ /// An array of an anonymous type has no name generated code could write, so the call is reported.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ArrayOfAnonymousType_Reported()
+ {
+ const string Source = Preamble + """
+ namespace TestApp
+ {
+ public sealed class Vm : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+ public int A { get; set; }
+ }
+
+ public static class Outer
+ {
+ public static void Run() => new Vm().WhenAnyValue(x => x.A, a => new[] { new { a } });
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(Source);
+ await Assert.That(diagnostics.Length).IsEqualTo(1);
+ }
+
/// A protected nested value type reached through the path is reported.
/// A task representing the asynchronous test operation.
[Test]
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedCallSiteEmitterTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedCallSiteEmitterTests.cs
new file mode 100644
index 00000000..08e60e77
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedCallSiteEmitterTests.cs
@@ -0,0 +1,106 @@
+// 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 System.Runtime.CompilerServices;
+using ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+using ReactiveUI.Binding.SourceGenerators.Models;
+using ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.CodeGeneration;
+
+/// Tests for , which moves call sites into the caller's partial class.
+public class HostedCallSiteEmitterTests
+{
+ /// The text the stand-in emitter writes for the call sites that are not hosted.
+ private const string MainFile = "MAIN";
+
+ /// The data of the hosted call site's location.
+ private const string LocationData = "hosted";
+
+ /// The name of the class the stand-in emitter writes its members in.
+ private const string ClassName = "Generated";
+
+ /// The file the stand-in emitter writes for hosted call sites, claiming the hosted location.
+ private const string ClaimingFile = """
+ namespace Gen
+ {
+ internal static partial class Generated
+ {
+ [global::System.Runtime.CompilerServices.InterceptsLocation(1, "hosted")]
+ internal static int __Intercept_Probe_A(
+ this global::App.Host.Inner value)
+ => 0;
+ }
+ }
+ """;
+
+ /// The same file, claiming a call site that is not hosted.
+ private static readonly string UnclaimedFile = ClaimingFile.Replace("\"hosted\"", "\"elsewhere\"", StringComparison.Ordinal);
+
+ /// The features of a build that intercepts, with the stand-in emitter's class name.
+ private static readonly LanguageFeatures Intercepting = new(
+ true,
+ true,
+ true,
+ "Gen",
+ SupportsInterceptors: true,
+ GeneratedClassName: ClassName);
+
+ /// A hosted call to a method with no type parameters and no constraints.
+ private static readonly HostedCall PlainCall = new(
+ "global::App.Host",
+ new("App", new(["partial class Host"])),
+ new([]),
+ new([]),
+ "int",
+ "int",
+ new([new HostedParameter("value", "object", "global::App.Host.Inner")]));
+
+ /// When the emitter writes nothing for the hosted call sites, the file is the one for the others.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task Compose_HostedFileNotWritten_ReturnsTheUnhostedFile() =>
+ await Assert.That(Compose(static calls => calls.Any(static c => c.Host is not null) ? null : MainFile)).IsEqualTo(MainFile);
+
+ /// A hosted file without the generated class has nothing to move, so the file is the one for the others.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task Compose_HostedFileWithoutTheClass_ReturnsTheUnhostedFile() =>
+ await Assert.That(Compose(static calls => calls.Any(static c => c.Host is not null) ? "namespace Gen\n{\n}\n" : MainFile))
+ .IsEqualTo(MainFile);
+
+ /// A hosted file that claims no call site has nothing to intercept, so the file is the one for the others.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task Compose_HostedFileClaimingNothing_ReturnsTheUnhostedFile() =>
+ await Assert.That(Compose(static calls => calls.Any(static c => c.Host is not null) ? UnclaimedFile : MainFile))
+ .IsEqualTo(MainFile);
+
+ /// A method with no type parameters gets an interceptor and an entry point with none either.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task Compose_MethodWithoutTypeParameters_WritesNoTypeArguments()
+ {
+ var file = Compose(static calls => calls.Any(static c => c.Host is not null) ? ClaimingFile : MainFile)!;
+
+ await Assert.That(file).StartsWith(MainFile);
+ await Assert.That(file).Contains("this object value)");
+ await Assert.That(file).Contains("=> __ReactiveUIHostedBindings.__Intercept_Probe_A((global::App.Host.Inner)(object)value);");
+ await Assert.That(file).DoesNotContain("<");
+ }
+
+ /// Composes one unhosted and one hosted call site through a stand-in emitter.
+ /// The stand-in emitter.
+ /// The composed file.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static string? Compose(Func, string?> emit) =>
+ HostedCallSiteEmitter.Compose(
+ [
+ ModelFactory.CreateInvocationInfo(),
+ ModelFactory.CreateInvocationInfo() with { Interceptor = new(1, LocationData), Host = PlainCall },
+ ],
+ Intercepting,
+ (calls, _) => emit(calls));
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedSourceReaderTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedSourceReaderTests.cs
new file mode 100644
index 00000000..9527f926
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedSourceReaderTests.cs
@@ -0,0 +1,85 @@
+// 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 ReactiveUI.Binding.SourceGenerators.CodeGeneration;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.CodeGeneration;
+
+/// Tests for , which reads an API's file back so its members can move.
+public class HostedSourceReaderTests
+{
+ /// The name of the class the files in these tests write their members in.
+ private const string ClassName = "Generated";
+
+ /// A file whose interceptor opens its parameter list on its own line.
+ private const string InlineReceiverFile = """
+ namespace Gen
+ {
+ internal static partial class Generated
+ {
+ [global::System.Runtime.CompilerServices.InterceptsLocation(1, "first")]
+ [global::System.Runtime.CompilerServices.InterceptsLocation(1, "second")]
+ internal static int __Intercept_Probe_A(this int value) => value;
+
+ private static int Helper(int value) => value;
+ }
+ }
+
+ namespace Other
+ {
+ partial class Accessor
+ {
+ }
+ }
+ """;
+
+ /// A file that has no generated class.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryRead_WithoutTheGeneratedClass_ReturnsFalse() =>
+ await Assert.That(HostedSourceReader.TryRead("namespace Gen\n{\n}\n", ClassName, out _)).IsFalse();
+
+ /// A file whose generated class never closes has no body to read.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryRead_WithoutTheClosingBraces_ReturnsFalse() =>
+ await Assert.That(HostedSourceReader.TryRead(
+ "namespace Gen\n{\n internal static partial class Generated\n {\n private static int Value;\n",
+ ClassName,
+ out _))
+ .IsFalse();
+
+ /// Every attribute on an interceptor claims its call site for that interceptor, and the attributes are dropped.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryRead_ClaimsEveryAttributedCallSiteForTheInterceptorThatFollows()
+ {
+ _ = HostedSourceReader.TryRead(InlineReceiverFile, ClassName, out var read);
+
+ await Assert.That(read.Claims["first"]).IsEqualTo("__Intercept_Probe_A");
+ await Assert.That(read.Claims["second"]).IsEqualTo("__Intercept_Probe_A");
+ await Assert.That(string.Join("\n", read.Members)).DoesNotContain("InterceptsLocation");
+ }
+
+ /// A receiver on the interceptor's own line loses its this, and later lines keep theirs.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryRead_ReceiverOnTheDeclarationLine_LosesThis()
+ {
+ _ = HostedSourceReader.TryRead(InlineReceiverFile, ClassName, out var read);
+
+ await Assert.That(read.Members[0]).IsEqualTo("internal static int __Intercept_Probe_A(int value) => value;");
+ await Assert.That(read.Members[2]).IsEqualTo("private static int Helper(int value) => value;");
+ }
+
+ /// What the file declares after the generated namespace stays at namespace level.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryRead_TextAfterTheNamespace_IsKeptAsTrailing()
+ {
+ _ = HostedSourceReader.TryRead(InlineReceiverFile, ClassName, out var read);
+
+ await Assert.That(read.Trailing).StartsWith("namespace Other");
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteDiagnostics.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteDiagnostics.cs
new file mode 100644
index 00000000..d4295a1e
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteDiagnostics.cs
@@ -0,0 +1,52 @@
+// 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.
+
+extern alias analyzer;
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+/// Runs the analyzers that report a binding call over what the generator produced, as a build does.
+internal static class CallSiteDiagnostics
+{
+ /// The method every scenario runs through.
+ private const string ScenarioMethodName = "Run";
+
+ /// Runs every analyzer that reports a binding call, and keeps the errors.
+ /// The generator result.
+ /// The ids of the errors reported.
+ internal static async Task> ErrorsAsync(GeneratorTestResult result)
+ {
+ var diagnostics = await result.OutputCompilation
+ .WithAnalyzers(
+ [
+ new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.BindingInvocationAnalyzer(),
+ new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.UnreachableTypeAnalyzer(),
+ new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.NoGeneratedBindingAnalyzer(),
+ new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.ToPropertyAnalyzer(),
+ ])
+ .GetAnalyzerDiagnosticsAsync();
+
+ return [.. diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.Id)];
+ }
+
+ /// Runs a scenario's Run method and returns what it recorded.
+ /// The generator result, which has to compile.
+ /// The values the scenario returned.
+ internal static int[] RunScenario(GeneratorTestResult result)
+ {
+ var (assembly, context) = TestHelper.EmitAndLoad(result);
+ try
+ {
+ return (int[])assembly.GetType(UnnameableTypeScenarios.ScenarioTypeName)!.GetMethod(ScenarioMethodName)!.Invoke(null, null)!;
+ }
+ finally
+ {
+ context.Unload();
+ }
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteHostingTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteHostingTests.cs
new file mode 100644
index 00000000..ca752dd0
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteHostingTests.cs
@@ -0,0 +1,115 @@
+// 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 Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ReactiveUI.Binding.SourceGenerators.Helpers;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+/// Tests for .
+public class CallSiteHostingTests
+{
+ /// A method that uses every kind of constraint, and a type parameter that has none.
+ private const string ConstrainedSource = """
+ #nullable enable
+ public static class Constrained
+ {
+ public static void M()
+ where TA : class?
+ where TB : unmanaged
+ where TC : struct
+ where TD : notnull
+ where TE : class, System.IDisposable, new()
+ {
+ }
+ }
+ """;
+
+ /// The clauses the method above declares, written as a generic interceptor repeats them.
+ private static readonly string[] ExpectedClauses =
+ [
+ "where TA : class?",
+ "where TB : unmanaged",
+ "where TC : struct",
+ "where TD : notnull",
+ "where TE : class, global::System.IDisposable, new()",
+ ];
+
+ /// A type with no type parameter in it, such as dynamic, names only type parameters in scope.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task UsesOnly_DynamicType_ReturnsTrue() =>
+ await Assert.That(CallSiteHosting.UsesOnly(TestHelper.CreateCompilation(string.Empty).DynamicType, [])).IsTrue();
+
+ /// Combining a weaker kind with a stronger one keeps the stronger, and the types of both appear once.
+ /// The first kind, or null.
+ /// The second kind, or null.
+ /// The combined constraints.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [Arguments(null, "class", "class, IA, IB")]
+ [Arguments("class", null, "class, IA, IB")]
+ [Arguments("class", "class", "class, IA, IB")]
+ [Arguments("notnull", "class", "class, IA, IB")]
+ [Arguments("class?", "class", "class, IA, IB")]
+ [Arguments("class", "notnull", "class, IA, IB")]
+ public async Task TypeParameterConstraints_With_KeepsTheStrongerKind(string? first, string? second, string expected)
+ {
+ var combined = new CallSiteHosting.TypeParameterConstraints(first, ["IA"], false)
+ .With(new(second, ["IA", "IB"], false));
+
+ await Assert.That(combined.Format()).IsEqualTo(expected);
+ }
+
+ /// A constructor constraint on either side is kept.
+ /// Whether the first set has a constructor constraint.
+ /// Whether the second set has one.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [Arguments(true, false)]
+ [Arguments(false, true)]
+ public async Task TypeParameterConstraints_With_KeepsAConstructorConstraintFromEither(bool first, bool second) =>
+ await Assert.That(new CallSiteHosting.TypeParameterConstraints("class", [], first).With(new("class", [], second)).Format())
+ .IsEqualTo("class, new()");
+
+ /// A constructor constraint is written last, and not at all beside a value-type kind, which C# refuses.
+ /// The kind constraint, or null.
+ /// The written constraints.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [Arguments("class", "class, new()")]
+ [Arguments("struct", "struct")]
+ [Arguments("unmanaged", "unmanaged")]
+ public async Task TypeParameterConstraints_Format_PlacesTheConstructorConstraint(string kind, string expected) =>
+ await Assert.That(new CallSiteHosting.TypeParameterConstraints(kind, [], true).Format()).IsEqualTo(expected);
+
+ /// A type parameter with no constraint at all writes nothing.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TypeParameterConstraints_Format_WithoutConstraints_ReturnsNull() =>
+ await Assert.That(new CallSiteHosting.TypeParameterConstraints(null, [], false).Format()).IsNull();
+
+ /// Every kind of constraint is repeated, and a type parameter with none gets no clause.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ConstraintClauses_RepeatsEveryKindOfConstraint()
+ {
+ var method = TestHelper.CreateCompilation(ConstrainedSource, LanguageVersion.CSharp11)
+ .GetTypeByMetadataName("Constrained")!
+ .GetMembers("M")
+ .OfType()
+ .Single();
+
+ var names = new Dictionary(SymbolEqualityComparer.Default);
+ foreach (var typeParameter in method.TypeParameters)
+ {
+ names[typeParameter] = typeParameter.Name;
+ }
+
+ var clauses = CallSiteHosting.ConstraintClauses(method.TypeParameters, names);
+
+ await Assert.That(clauses.ToArray()).IsEquivalentTo(ExpectedClauses);
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CommandExtractorHelperTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CommandExtractorHelperTests.cs
index e8f30960..d18304f0 100644
--- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CommandExtractorHelperTests.cs
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CommandExtractorHelperTests.cs
@@ -404,6 +404,7 @@ public class Caller
memberAccess,
invocation.ArgumentList.Arguments,
model,
+ ReachScope.Assembly(model.Compilation),
CancellationToken.None);
await Assert.That(sides!.Value.ViewTypeFullName).IsEqualTo("global::TestApp.View");
@@ -683,6 +684,7 @@ public class ButtonControl
memberAccess,
invocation.ArgumentList.Arguments,
model,
+ ReachScope.Assembly(model.Compilation),
CancellationToken.None);
}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.NamelessTypes.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.NamelessTypes.cs
new file mode 100644
index 00000000..968f164c
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.NamelessTypes.cs
@@ -0,0 +1,201 @@
+// 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.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ReactiveUI.Binding.SourceGenerators.Helpers;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+/// Tests for the types generated code has no name for, and for calls whose only such type is the result.
+public partial class ExtractorValidationTests
+{
+ /// The position of the call with an anonymous result among the statements of Run.
+ private const int AnonymousResultCall = 0;
+
+ /// The position of the call to a method with no selector.
+ private const int NoSelectorCall = 1;
+
+ /// The position of the call to a method of a generic class.
+ private const int GenericClassCall = 2;
+
+ /// The position of the call on a private nested receiver.
+ private const int HiddenReceiverCall = 3;
+
+ /// The position of the call whose result is the calling method's type parameter.
+ private const int TypeParameterResultCall = 4;
+
+ /// The position of TRet among the type parameters of Api.Watch.
+ private const int WatchResultOrdinal = 2;
+
+ /// Calls whose types range from all named to out of reach in different places, one per line of Run.
+ private const string CallsSource = """
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ public static class Api
+ {
+ public static TRet Watch(this TObj o, Func p, Func selector) => default!;
+
+ public static T1 NoSelector(this TObj o, Func p) => default!;
+ }
+
+ public static class Generic
+ {
+ public static TRet Watch(TObj o, Func selector) => default!;
+ }
+
+ public class Vm
+ {
+ public int A;
+ }
+
+ public static class Uses
+ {
+ public static void Run()
+ {
+ new Vm().Watch(x => x.A, a => new { a });
+ new Vm().NoSelector(x => new { x.A });
+ Generic.Watch(new Vm(), v => new { v });
+ new Hidden().Watch(x => 1, a => new { a });
+ new Vm().Watch(x => x.A, a => default(TP));
+ var enumerator = new[] { new { A = 1 } }.ToList().GetEnumerator();
+ var plain = new List();
+ }
+
+ private sealed class Hidden
+ {
+ }
+ }
+
+ file sealed class FileLocal
+ {
+ public sealed class Nested
+ {
+ }
+ }
+ """;
+
+ /// An array of a type parameter is built from that type parameter.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ContainsTypeParameter_ArrayOfATypeParameter_ReturnsTrue()
+ {
+ var typeParameter = Call(TypeParameterResultCall).Method.TypeArguments[WatchResultOrdinal];
+ var array = Compile().CreateArrayTypeSymbol(typeParameter);
+
+ await Assert.That(ExtractorValidation.ContainsTypeParameter(array)).IsTrue();
+ }
+
+ /// A type with no type arguments and no element type, such as dynamic, is not nameless.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ContainsNamelessType_DynamicType_ReturnsFalse() =>
+ await Assert.That(ExtractorValidation.ContainsNamelessType(TestHelper.CreateCompilation(string.Empty).DynamicType)).IsFalse();
+
+ /// A type nested in a generic closed over an anonymous type carries the anonymous type through its container.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ContainsNamelessType_EnumeratorOfAListOfAnonymousType_ReturnsTrue() =>
+ await Assert.That(ExtractorValidation.ContainsNamelessType(LocalType("enumerator"))).IsTrue();
+
+ /// A generic closed over named types is not nameless.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ContainsNamelessType_GenericOfNamedTypes_ReturnsFalse() =>
+ await Assert.That(ExtractorValidation.ContainsNamelessType(LocalType("plain"))).IsFalse();
+
+ /// A type nested in a file-local type has no name outside its file, like the file-local type itself.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task ContainsNamelessType_TypeNestedInAFileLocalType_ReturnsTrue()
+ {
+ var nested = Compile().GetSymbolsWithName("Nested", SymbolFilter.Type).OfType().Single();
+
+ await Assert.That(ExtractorValidation.ContainsNamelessType(nested)).IsTrue();
+ }
+
+ /// An anonymous selector result is the one type argument a generic interceptor takes, at its position.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryFindUnnameableResult_AnonymousResult_FindsItsPosition()
+ {
+ var (method, compilation) = Call(AnonymousResultCall);
+
+ await Assert.That(ExtractorValidation.TryFindUnnameableResult(method, compilation, out var ordinal)).IsTrue();
+ await Assert.That(ordinal).IsEqualTo(WatchResultOrdinal);
+ }
+
+ /// A method with no selector has no result to take as a type parameter.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryFindUnnameableResult_NoSelector_ReturnsFalse()
+ {
+ var (method, compilation) = Call(NoSelectorCall);
+
+ await Assert.That(ExtractorValidation.TryFindUnnameableResult(method, compilation, out _)).IsFalse();
+ }
+
+ /// A method of a generic class would need the class's type parameters as well, so it is refused.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryFindUnnameableResult_GenericContainingType_ReturnsFalse()
+ {
+ var (method, compilation) = Call(GenericClassCall);
+
+ await Assert.That(ExtractorValidation.TryFindUnnameableResult(method, compilation, out _)).IsFalse();
+ }
+
+ /// A private type anywhere but the result has to be named, so the call is refused.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryFindUnnameableResult_AnotherTypeArgumentOutOfReach_ReturnsFalse()
+ {
+ var (method, compilation) = Call(HiddenReceiverCall);
+
+ await Assert.That(ExtractorValidation.TryFindUnnameableResult(method, compilation, out _)).IsFalse();
+ }
+
+ /// A result built from the calling code's type parameter is closed over by the interceptor, like any other result.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ public async Task TryFindUnnameableResult_ResultIsATypeParameter_ReturnsTrue()
+ {
+ var (method, compilation) = Call(TypeParameterResultCall);
+
+ await Assert.That(ExtractorValidation.TryFindUnnameableResult(method, compilation, out _)).IsTrue();
+ }
+
+ /// Compiles the calls the tests read.
+ /// The compilation.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Compilation Compile() => TestHelper.CreateCompilation(CallsSource, LanguageVersion.CSharp11);
+
+ /// Reads the type of a local declared in Run.
+ /// The local's name.
+ /// The local's type.
+ private static ITypeSymbol LocalType(string name)
+ {
+ var compilation = Compile();
+ var tree = compilation.SyntaxTrees.First();
+ var declarator = tree.GetRoot().DescendantNodes().OfType().Single(d => d.Identifier.ValueText == name);
+ return ((ILocalSymbol)compilation.GetSemanticModel(tree).GetDeclaredSymbol(declarator)!).Type;
+ }
+
+ /// Reads the method one of the calls in Run resolves to.
+ /// The call's position among the statements of Run.
+ /// The resolved method and the compilation it belongs to.
+ private static (IMethodSymbol Method, Compilation Compilation) Call(int index)
+ {
+ var compilation = Compile();
+ var tree = compilation.SyntaxTrees.First();
+ var statement = tree.GetRoot().DescendantNodes().OfType().Single(static m => m.Identifier.ValueText == "Run")
+ .Body!.Statements[index];
+ var invocation = statement.DescendantNodes().OfType().First();
+ return ((IMethodSymbol)compilation.GetSemanticModel(tree).GetSymbolInfo(invocation).Symbol!, compilation);
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.cs
index 95e24fd1..15b418d4 100644
--- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.cs
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.cs
@@ -11,7 +11,7 @@
namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
/// Unit tests for helper methods. Tests the guard-clause branches extracted from extractor classes.
-public class ExtractorValidationTests
+public partial class ExtractorValidationTests
{
/// The selector name these tests generate against.
private const string SelectorName = "selector";
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.TypeParameters.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.TypeParameters.cs
new file mode 100644
index 00000000..fc6895eb
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.TypeParameters.cs
@@ -0,0 +1,406 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+/// Call sites built from the calling code's type parameters.
+internal static partial class UnnameableTypeScenarios
+{
+ /// A WhenAnyValue call on a receiver typed by the calling method's type parameter.
+ internal const string TypeParameterReceiver = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new HasA { A = 1 };
+ var recorder = Watch(vm);
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private static Recorder Watch(T vm)
+ where T : class, IHasA => Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ }
+
+ public interface IHasA : INotifyPropertyChanged
+ {
+ int A { get; set; }
+ }
+
+ public sealed class HasA : IHasA
+ {
+ private int _a;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A
+ {
+ get => _a;
+ set
+ {
+ _a = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(A)));
+ }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a generic box whose value is typed by the calling method's type parameter.
+ internal const string TypeParameterValue = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var box = new Box { Value = 1 };
+ var recorder = Watch(box);
+ box.Value = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private static Recorder Watch(Box box) => Recorder.Watch(box.WhenAnyValue(x => x.Value));
+ }
+
+ public sealed class Box : INotifyPropertyChanged
+ {
+ private T _value = default!;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public T Value
+ {
+ get => _value;
+ set
+ {
+ _value = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
+ }
+ }
+
+ public string Name { get; set; } = string.Empty;
+ }
+ """;
+
+ /// A WhenAnyValue call whose selector returns the calling method's type parameter.
+ internal const string TypeParameterResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1 };
+ var recorder = Project(vm, a => a * 2);
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private static Recorder Project(Vm vm, Func map) => Recorder.Watch(vm.WhenAnyValue(x => x.A, a => map(a)));
+ }
+ """;
+
+ /// A OneWayBind call in a generic view base class, onto a view model typed by the class's type parameter.
+ internal const string TypeParameterViewBase = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new HasA { A = 1 };
+ var view = new HasAView { ViewModel = vm };
+ using (view.BindA())
+ {
+ vm.A = 3;
+ }
+
+ return new[] { view.Shown };
+ }
+ }
+
+ public interface IHasA : INotifyPropertyChanged
+ {
+ int A { get; set; }
+ }
+
+ public sealed class HasA : IHasA
+ {
+ private int _a;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A
+ {
+ get => _a;
+ set
+ {
+ _a = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(A)));
+ }
+ }
+ }
+
+ public abstract class ViewBase : INotifyPropertyChanged, IViewFor
+ where TViewModel : class, IHasA
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public TViewModel? ViewModel { get; set; }
+
+ object? IViewFor.ViewModel
+ {
+ get => ViewModel;
+ set => ViewModel = (TViewModel?)value;
+ }
+
+ public int Shown { get; set; }
+
+ public IDisposable BindA() => this.OneWayBind(ViewModel, vm => vm.A, v => v.Shown);
+ }
+
+ public sealed class HasAView : ViewBase
+ {
+ }
+ """;
+
+ /// A WhenAnyValue call on a box of arrays, whose type parameter only appears inside an array.
+ internal const string TypeParameterInsideArray = """
+ public static class Scenario
+ {
+ public static int[] Run() => new[] { Watch(new Box()).Values.Count };
+
+ private static Recorder Watch(Box box) => Recorder.Watch(box.WhenAnyValue(x => x.Value));
+ }
+
+ public sealed class Box : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public T Value { get; set; } = default!;
+ }
+ """;
+
+ /// A WhenAnyValue call whose type parameter is constrained by another the call does not use.
+ internal const string ConstraintNamesAnotherTypeParameter = """
+ public static class Scenario
+ {
+ public static int[] Run() => new[] { Watch(new Box()).Values.Count };
+
+ private static Recorder Watch(Box box)
+ where T : IKeyed => Recorder.Watch(box.WhenAnyValue(x => x.Value));
+ }
+
+ public interface IKeyed
+ {
+ TKey Key { get; }
+ }
+
+ public sealed class Item : IKeyed
+ {
+ public int Key => 1;
+ }
+
+ public sealed class Box : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public T Value { get; set; } = default!;
+ }
+ """;
+
+ /// A WhenAnyValue call on a generic box, reading a property that does not carry the type parameter.
+ internal const string UnmappableTypeParameter = """
+ public static class Scenario
+ {
+ public static int[] Run() => new[] { Watch(new Box()).Values.Count };
+
+ private static Recorder Watch(Box box) => Recorder.Watch(box.WhenAnyValue(x => x.Name));
+ }
+
+ public sealed class Box : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public string Name { get; set; } = string.Empty;
+ }
+ """;
+
+ ///
+ /// One call to every generated API but ToProperty, made in a generic method between a view and a view model typed by
+ /// its type parameters. Returns the same values as without the ToProperty value and the
+ /// unhosted call.
+ ///
+ internal const string TypeParameterEveryApi = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var executed = 0;
+ var ticks = new Subject();
+ var vm = new ViewModel { Age = 1, Name = "a", Ticks = ticks, Save = new RecordingCommand(() => executed++) };
+ var view = new View { ViewModel = vm };
+ var values = Bind(vm, view, ticks);
+ values[7] = executed;
+ return values;
+ }
+
+ private static int[] Bind(TViewModel vm, TView view, Subject ticks)
+ where TViewModel : class, IViewModel
+ where TView : class, IView
+ {
+ var handled = 0;
+ var pushes = new Subject();
+ var changed = Recorder.Watch(vm.WhenChanged(x => x.Age));
+ var changing = Recorder.Watch(vm.WhenChanging(x => x.Age));
+ var any = Recorder.Watch(vm.WhenAny(x => x.Age, change => change.Value));
+ var anyObservable = Recorder.Watch(vm.WhenAnyObservable(x => x.Ticks));
+ var bindings = new[]
+ {
+ vm.BindOneWay(view, x => x.Age, v => v.AgeCopy),
+ vm.BindTwoWay(view, x => x.Name, v => v.NameCopy),
+ view.OneWayBind(vm, x => x.Age, v => v.AgeFromView),
+ view.Bind(vm, x => x.Name, v => v.NameFromView),
+ pushes.BindTo(vm, x => x.Pushed),
+ pushes.InvokeCommand(vm, x => x.Save),
+ view.BindInteraction(vm, x => x.Confirm, context =>
+ {
+ handled++;
+ context.SetOutput(true);
+ return System.Threading.Tasks.Task.CompletedTask;
+ }),
+ view.BindCommand(vm, x => x.Save, v => v.SaveButton),
+ };
+
+ vm.Age = 2;
+ ticks.OnNext(5);
+ pushes.OnNext(7);
+ view.NameCopy = "b";
+ view.SaveButton.PerformClick();
+ _ = Recorder.Watch(vm.Confirm.Handle("question"));
+
+ foreach (var binding in bindings)
+ {
+ binding.Dispose();
+ }
+
+ return new[]
+ {
+ changed.Values.Last(),
+ any.Values.Last(),
+ anyObservable.Values.Last(),
+ changing.Values.Count > 0 ? 1 : 0,
+ view.AgeCopy,
+ view.AgeFromView,
+ vm.Pushed,
+ 0,
+ handled,
+ vm.Name == "b" && view.NameFromView == "b" ? 1 : 0,
+ };
+ }
+ }
+
+ public interface IViewModel : INotifyPropertyChanged, INotifyPropertyChanging
+ {
+ int Age { get; set; }
+
+ string? Name { get; set; }
+
+ IObservable? Ticks { get; }
+
+ ICommand? Save { get; }
+
+ IInteraction Confirm { get; }
+
+ int Pushed { get; set; }
+ }
+
+ public interface IView : INotifyPropertyChanged, IViewFor
+ where TViewModel : class
+ {
+ int AgeCopy { get; set; }
+
+ string? NameCopy { get; set; }
+
+ int AgeFromView { get; set; }
+
+ string? NameFromView { get; set; }
+
+ int Pushed { get; set; }
+
+ MyButton SaveButton { get; }
+ }
+
+ public sealed class ViewModel : IViewModel
+ {
+ private int _age;
+ private string? _name;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public event PropertyChangingEventHandler? PropertyChanging;
+
+ public int Age
+ {
+ get => _age;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Age)));
+ _age = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
+ }
+ }
+
+ public string? Name
+ {
+ get => _name;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Name)));
+ _name = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
+ }
+ }
+
+ public IObservable? Ticks { get; set; }
+
+ public ICommand? Save { get; set; }
+
+ public IInteraction Confirm { get; } = new Interaction();
+
+ public int Pushed { get; set; }
+ }
+
+ public sealed class View : IView
+ {
+ private int _ageCopy;
+ private string? _nameCopy;
+ private int _ageFromView;
+ private string? _nameFromView;
+ private int _pushed;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ViewModel? ViewModel { get; set; }
+
+ object? IViewFor.ViewModel
+ {
+ get => ViewModel;
+ set => ViewModel = (ViewModel?)value;
+ }
+
+ public int AgeCopy { get => _ageCopy; set => Set(ref _ageCopy, value); }
+
+ public string? NameCopy { get => _nameCopy; set => Set(ref _nameCopy, value); }
+
+ public int AgeFromView { get => _ageFromView; set => Set(ref _ageFromView, value); }
+
+ public string? NameFromView { get => _nameFromView; set => Set(ref _nameFromView, value); }
+
+ public int Pushed { get => _pushed; set => Set(ref _pushed, value); }
+
+ public MyButton SaveButton { get; } = new MyButton();
+
+ private void Set(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string name = "")
+ {
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+ }
+ """;
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.UnreadablePaths.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.UnreadablePaths.cs
new file mode 100644
index 00000000..fa8e504d
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.UnreadablePaths.cs
@@ -0,0 +1,134 @@
+// 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.
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+/// Call sites whose paths name a private member or something other than a property, or that are written so the generator cannot read them.
+internal static partial class UnnameableTypeScenarios
+{
+ /// A WhenAnyValue call on a private property, made inside the partial class that declares it.
+ internal const string HostedPrivateMember = """
+ public sealed partial class Scenario : INotifyPropertyChanged
+ {
+ private int _hidden;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private int Hidden
+ {
+ get => _hidden;
+ set
+ {
+ _hidden = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hidden)));
+ }
+ }
+
+ public static int[] Run()
+ {
+ var scenario = new Scenario { Hidden = 1 };
+ var recorder = Recorder.Watch(scenario.WhenAnyValue(x => x.Hidden));
+ scenario.Hidden = 3;
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a private property of a class that is not partial.
+ internal const string UnhostablePrivateMember = """
+ public sealed class Scenario : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private int Hidden { get; set; }
+
+ public static int[] Run()
+ {
+ var scenario = new Scenario { Hidden = 1 };
+ var recorder = Recorder.Watch(scenario.WhenAnyValue(x => x.Hidden));
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call whose selector computes a value rather than naming a property.
+ internal const string ComputedPath = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var recorder = Recorder.Watch(new Vm { A = 1 }.WhenAnyValue(x => x.A + 1));
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A WhenChanged call written through the stub's declaring class.
+ internal const string StaticFormCall = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var recorder = Recorder.Watch(ReactiveUIBindingExtensions.WhenChanged(new Vm { A = 1 }, x => x.A));
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A WhenChanged call whose property path is held in a variable.
+ internal const string StoredPath = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ System.Linq.Expressions.Expression> path = x => x.A;
+ var recorder = Recorder.Watch(new Vm { A = 1 }.WhenChanged(path));
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A BindCommand call whose command parameter path is held in a variable.
+ internal const string StoredCommandParameter = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var executed = 0;
+ var view = new View { ViewModel = new ViewModel { Save = new RecordingCommand(() => executed++) } };
+ System.Linq.Expressions.Expression> parameter = x => x.Name;
+ using (view.BindCommand(view.ViewModel, vm => vm.Save, v => v.SaveButton, parameter))
+ {
+ view.SaveButton.PerformClick();
+ }
+
+ return new[] { executed };
+ }
+ }
+
+ public sealed class ViewModel : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ICommand? Save { get; set; }
+
+ public string? Name { get; set; }
+ }
+
+ public sealed class View : INotifyPropertyChanged, IViewFor
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ViewModel? ViewModel { get; set; }
+
+ object? IViewFor.ViewModel
+ {
+ get => ViewModel;
+ set => ViewModel = (ViewModel?)value;
+ }
+
+ public MyButton SaveButton { get; } = new MyButton();
+ }
+ """;
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.cs
new file mode 100644
index 00000000..3512bd50
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.cs
@@ -0,0 +1,736 @@
+// 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 System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+///
+/// Call sites whose types generated code cannot name: an anonymous type, a private or protected nested type, or a type
+/// built from one. Each scenario exposes TestApp.Scenario.Run(), which returns the values the call delivered.
+///
+internal static partial class UnnameableTypeScenarios
+{
+ /// The name of the type each scenario runs through.
+ internal const string ScenarioTypeName = "TestApp.Scenario";
+
+ /// The diagnostic reported for a call that names a type generated code cannot reach.
+ internal const string UnreachableTypeId = "RXUIBIND015";
+
+ /// The diagnostic reported for a call that has no generated binding.
+ internal const string NoGeneratedBindingId = "RXUIBIND021";
+
+ /// A WhenAnyValue call whose selector builds an anonymous type.
+ internal const string AnonymousWhenAnyValueResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1, B = 2 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A, x => x.B, (a, b) => new { a, b }));
+ vm.A = 3;
+ vm.B = 4;
+ return recorder.Values.Select(v => (v.a * 10) + v.b).ToArray();
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call whose selector builds a private nested type.
+ internal const string PrivateWhenAnyValueResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A, a => new Result(a)));
+ vm.A = 3;
+ return recorder.Values.Select(v => v.Value).ToArray();
+ }
+
+ private sealed class Result
+ {
+ public Result(int value) => Value = value;
+
+ public int Value { get; }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call whose selector builds a list closed over a private nested type.
+ internal const string PrivateTypeArgumentResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A, a => new List { new Result(a) }));
+ vm.A = 3;
+ return recorder.Values.Select(v => v[0].Value).ToArray();
+ }
+
+ protected sealed class Result
+ {
+ public Result(int value) => Value = value;
+
+ public int Value { get; }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call whose selector builds a tuple holding a private nested type.
+ internal const string PrivateTupleResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A, a => (a, new Result(a * 2))));
+ vm.A = 3;
+ return recorder.Values.Select(v => v.Item1 + v.Item2.Value).ToArray();
+ }
+
+ private sealed class Result
+ {
+ public Result(int value) => Value = value;
+
+ public int Value { get; }
+ }
+ }
+ """;
+
+ /// A WhenAny call whose selector builds an anonymous type from the observed change.
+ internal const string AnonymousWhenAnyResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAny(x => x.A, change => new { Seen = change.Value }));
+ vm.A = 3;
+ return recorder.Values.Select(v => v.Seen).ToArray();
+ }
+ }
+ """;
+
+ /// A WhenChanged call whose conversion builds an anonymous type.
+ internal const string AnonymousWhenChangedResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1, B = 2 };
+ var recorder = Recorder.Watch(vm.WhenChanged(x => x.A, x => x.B, (a, b) => new { Sum = a + b }));
+ vm.A = 3;
+ return recorder.Values.Select(v => v.Sum).ToArray();
+ }
+ }
+ """;
+
+ /// A WhenChanging call whose conversion builds an anonymous type.
+ internal const string AnonymousWhenChangingResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1, B = 2 };
+ var recorder = Recorder.Watch(vm.WhenChanging(x => x.A, x => x.B, (a, b) => new { Before = a + b }));
+ vm.A = 3;
+ return recorder.Values.Select(v => v.Before).ToArray();
+ }
+ }
+ """;
+
+ /// The same WhenChanging call with a named result, which the anonymous one has to match value for value.
+ internal const string NamedWhenChangingResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Vm { A = 1, B = 2 };
+ var recorder = Recorder.Watch(vm.WhenChanging(x => x.A, x => x.B, (a, b) => a + b));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+ }
+ """;
+
+ /// A WhenAnyObservable call whose selector builds an anonymous type.
+ internal const string AnonymousWhenAnyObservableResult = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var first = new Subject();
+ var second = new Subject();
+ var vm = new Vm { First = first, Second = second };
+ var recorder = Recorder.Watch(vm.WhenAnyObservable(x => x.First, x => x.Second, (a, b) => new { a, b }));
+ first.OnNext(1);
+ second.OnNext(2);
+ first.OnNext(3);
+ return recorder.Values.Select(v => (v.a * 10) + v.b).ToArray();
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a private nested view model, made inside the partial class that declares it.
+ internal const string HostedPrivateSource = """
+ public static partial class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Inner { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private sealed class Inner : INotifyPropertyChanged
+ {
+ private int _a;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A
+ {
+ get => _a;
+ set
+ {
+ _a = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(A)));
+ }
+ }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a private nested view model whose declaring class is not partial.
+ internal const string UnhostablePrivateSource = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Inner { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private sealed class Inner : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a file-local view model, which no other file can name.
+ internal const string FileLocalSource = """
+ public static class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new FileVm { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+ }
+
+ file sealed class FileVm : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+ """;
+
+ /// A WhenAnyValue call on a private nested view model, made inside a file-local partial class.
+ internal const string FileLocalHost = """
+ public static class Scenario
+ {
+ public static int[] Run() => FileHost.Run();
+ }
+
+ file static partial class FileHost
+ {
+ public static int[] Run()
+ {
+ var vm = new Inner { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private sealed class Inner : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+ }
+ """;
+
+ /// A WhenAnyValue call on a private nested view model, made inside a generic partial class.
+ internal const string GenericHost = """
+ public static class Scenario
+ {
+ public static int[] Run() => GenericHost.Run();
+ }
+
+ public static partial class GenericHost
+ {
+ public static int[] Run()
+ {
+ var vm = new Inner { A = 1 };
+ var recorder = Recorder.Watch(vm.WhenAnyValue(x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private sealed class Inner : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+ }
+ """;
+
+ /// A WhenChanged call on a private nested view model, written through the stub's declaring class.
+ internal const string HostedStaticFormCall = """
+ public static partial class Scenario
+ {
+ public static int[] Run()
+ {
+ var vm = new Inner { A = 1 };
+ var recorder = Recorder.Watch(ReactiveUIBindingExtensions.WhenChanged(vm, x => x.A));
+ vm.A = 3;
+ return recorder.Values.ToArray();
+ }
+
+ private sealed class Inner : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+ }
+ """;
+
+ /// A BindOneWay call with a scheduler between private nested types, made inside the partial class that declares them.
+ internal const string HostedSchedulerOverload = """
+ public static partial class Scenario
+ {
+ public static int[] Run()
+ {
+ var source = new Source { A = 1 };
+ var target = new Target();
+ using (source.BindOneWay(target, x => x.A, x => x.B, global::ReactiveUI.Primitives.Concurrency.ImmediateSequencer.Instance))
+ {
+ source.A = 3;
+ }
+
+ return new[] { target.B };
+ }
+
+ private sealed class Source : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int A { get; set; }
+ }
+
+ private sealed class Target
+ {
+ public int B { get; set; }
+ }
+ }
+ """;
+
+ /// A BindCommand call between a private nested view and view model, made inside the partial class that declares them.
+ internal const string HostedPrivateBindCommand = """
+ public static partial class Scenario
+ {
+ public static int[] Run()
+ {
+ var executed = 0;
+ var view = new View { ViewModel = new ViewModel { Save = new RecordingCommand(() => executed++) } };
+ using (view.BindCommand(view.ViewModel, vm => vm.Save, v => v.SaveButton))
+ {
+ view.SaveButton.PerformClick();
+ }
+
+ view.SaveButton.PerformClick();
+ return new[] { executed };
+ }
+
+ private sealed class ViewModel : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ICommand? Save { get; set; }
+ }
+
+ private sealed class View : INotifyPropertyChanged, IViewFor
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ViewModel? ViewModel { get; set; }
+
+ object? IViewFor.ViewModel
+ {
+ get => ViewModel;
+ set => ViewModel = (ViewModel?)value;
+ }
+
+ public MyButton SaveButton { get; } = new MyButton();
+ }
+ }
+ """;
+
+ ///
+ /// One call to every generated API between a private nested view and view model, made inside the partial class that
+ /// declares them. Returns, in order: the last WhenChanged, WhenAny and WhenAnyObservable values; 1 when WhenChanging
+ /// delivered; the values BindOneWay, OneWayBind and BindTo wrote; the InvokeCommand and BindInteraction counts; 1 when
+ /// BindTwoWay and Bind carried an edit both ways; the ToProperty value; and the value of a WhenChanged call on a
+ /// public view model, which is generated the usual way beside the hosted calls.
+ ///
+ internal const string HostedEveryApi = """
+ public static partial class Scenario
+ {
+ public static int[] Run()
+ {
+ var executed = 0;
+ var handled = 0;
+ var ticks = new Subject();
+ var pushes = new Subject();
+ var vm = new ViewModel { Age = 1, Name = "a", Ticks = ticks, Save = new RecordingCommand(() => executed++) };
+ var view = new View { ViewModel = vm };
+ vm.Attach();
+
+ var changed = Recorder.Watch(vm.WhenChanged(x => x.Age));
+ var unhosted = Recorder.Watch(new Vm { A = 4 }.WhenChanged(x => x.A));
+ var changing = Recorder.Watch(vm.WhenChanging(x => x.Age));
+ var any = Recorder.Watch(vm.WhenAny(x => x.Age, change => change.Value));
+ var anyObservable = Recorder.Watch(vm.WhenAnyObservable(x => x.Ticks));
+ var bindings = new[]
+ {
+ vm.BindOneWay(view, x => x.Age, v => v.AgeCopy),
+ vm.BindTwoWay(view, x => x.Name, v => v.NameCopy),
+ view.OneWayBind(vm, x => x.Age, v => v.AgeFromView),
+ view.Bind(vm, x => x.Name, v => v.NameFromView),
+ pushes.BindTo(view, v => v.Pushed),
+ pushes.InvokeCommand(vm, x => x.Save),
+ view.BindInteraction(vm, x => x.Confirm, context =>
+ {
+ handled++;
+ context.SetOutput(true);
+ return System.Threading.Tasks.Task.CompletedTask;
+ }),
+ };
+
+ vm.Age = 2;
+ ticks.OnNext(5);
+ pushes.OnNext(7);
+ view.NameCopy = "b";
+ _ = Recorder.Watch(vm.Confirm.Handle("question"));
+
+ foreach (var binding in bindings)
+ {
+ binding.Dispose();
+ }
+
+ return new[]
+ {
+ changed.Values.Last(),
+ any.Values.Last(),
+ anyObservable.Values.Last(),
+ changing.Values.Count > 0 ? 1 : 0,
+ view.AgeCopy,
+ view.AgeFromView,
+ view.Pushed,
+ executed,
+ handled,
+ vm.Name == "b" && view.NameFromView == "b" ? 1 : 0,
+ vm.Doubled,
+ unhosted.Values.Last(),
+ };
+ }
+
+ private sealed partial class ViewModel : INotifyPropertyChanged, INotifyPropertyChanging
+ {
+ private int _age;
+ private string? _name;
+ private ObservableAsPropertyHelper? _doubled;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public event PropertyChangingEventHandler? PropertyChanging;
+
+ public int Age
+ {
+ get => _age;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Age)));
+ _age = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
+ }
+ }
+
+ public string? Name
+ {
+ get => _name;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Name)));
+ _name = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
+ }
+ }
+
+ public int Doubled => _doubled?.Value ?? 0;
+
+ public IObservable? Ticks { get; set; }
+
+ public ICommand? Save { get; set; }
+
+ public IInteraction Confirm { get; } = new Interaction();
+
+ public void Attach() => _doubled = this.WhenAnyValue(x => x.Age, a => a * 2).ToProperty(this, x => x.Doubled);
+ }
+
+ private sealed class View : INotifyPropertyChanged, IViewFor
+ {
+ private int _ageCopy;
+ private string? _nameCopy;
+ private int _ageFromView;
+ private string? _nameFromView;
+ private int _pushed;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ViewModel? ViewModel { get; set; }
+
+ object? IViewFor.ViewModel
+ {
+ get => ViewModel;
+ set => ViewModel = (ViewModel?)value;
+ }
+
+ public int AgeCopy { get => _ageCopy; set => Set(ref _ageCopy, value); }
+
+ public string? NameCopy { get => _nameCopy; set => Set(ref _nameCopy, value); }
+
+ public int AgeFromView { get => _ageFromView; set => Set(ref _ageFromView, value); }
+
+ public string? NameFromView { get => _nameFromView; set => Set(ref _nameFromView, value); }
+
+ public int Pushed { get => _pushed; set => Set(ref _pushed, value); }
+
+ private void Set(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string name = "")
+ {
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+ }
+ }
+ """;
+
+ /// The usings, view model, and recorder every scenario shares.
+ private const string Shared = """
+ using System;
+ using System.Collections.Generic;
+ using System.ComponentModel;
+ using System.Linq;
+ using System.Windows.Input;
+ using ReactiveUI.Binding;
+
+ namespace TestApp
+ {
+ public sealed class Vm : INotifyPropertyChanged, INotifyPropertyChanging
+ {
+ private int _a;
+ private int _b;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public event PropertyChangingEventHandler? PropertyChanging;
+
+ public int A
+ {
+ get => _a;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(A)));
+ _a = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(A)));
+ }
+ }
+
+ public int B
+ {
+ get => _b;
+ set
+ {
+ PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(B)));
+ _b = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(B)));
+ }
+ }
+
+ public IObservable? First { get; set; }
+
+ public IObservable? Second { get; set; }
+ }
+
+ public sealed class Subject : IObservable
+ {
+ private readonly List> _observers = new List>();
+
+ public void OnNext(T value)
+ {
+ foreach (var observer in _observers.ToArray())
+ {
+ observer.OnNext(value);
+ }
+ }
+
+ public IDisposable Subscribe(IObserver observer)
+ {
+ _observers.Add(observer);
+ return new Unsubscriber(() => _observers.Remove(observer));
+ }
+ }
+
+ public sealed class Unsubscriber : IDisposable
+ {
+ private readonly Action _dispose;
+
+ public Unsubscriber(Action dispose) => _dispose = dispose;
+
+ public void Dispose() => _dispose();
+ }
+
+ public sealed class Recorder : IObserver
+ {
+ public List Values { get; } = new List();
+
+ public void OnNext(T value) => Values.Add(value);
+
+ public void OnError(Exception error) => throw error;
+
+ public void OnCompleted()
+ {
+ }
+ }
+
+ public sealed class RecordingCommand : ICommand
+ {
+ private readonly Action _onExecute;
+
+ public RecordingCommand(Action onExecute) => _onExecute = onExecute;
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter) => true;
+
+ public void Execute(object? parameter) => _onExecute();
+ }
+
+ public sealed class MyButton
+ {
+ public event EventHandler? Click;
+
+ public void PerformClick() => Click?.Invoke(this, EventArgs.Empty);
+ }
+
+ public static class Recorder
+ {
+ public static Recorder Watch(IObservable source)
+ {
+ var recorder = new Recorder();
+ source.Subscribe(recorder);
+ return recorder;
+ }
+ }
+
+ SCENARIO
+ }
+ """;
+
+ /// Finds a scenario by the name of the constant that holds it.
+ /// The constant's name.
+ /// The scenario's Scenario type.
+ /// No scenario has the name.
+ internal static string Named(string name) => name switch
+ {
+ nameof(AnonymousWhenAnyValueResult) => AnonymousWhenAnyValueResult,
+ nameof(PrivateWhenAnyValueResult) => PrivateWhenAnyValueResult,
+ nameof(PrivateTypeArgumentResult) => PrivateTypeArgumentResult,
+ nameof(PrivateTupleResult) => PrivateTupleResult,
+ nameof(AnonymousWhenAnyResult) => AnonymousWhenAnyResult,
+ nameof(AnonymousWhenChangedResult) => AnonymousWhenChangedResult,
+ nameof(AnonymousWhenChangingResult) => AnonymousWhenChangingResult,
+ nameof(AnonymousWhenAnyObservableResult) => AnonymousWhenAnyObservableResult,
+ nameof(HostedPrivateSource) => HostedPrivateSource,
+ nameof(HostedPrivateBindCommand) => HostedPrivateBindCommand,
+ nameof(HostedEveryApi) => HostedEveryApi,
+ nameof(UnhostablePrivateSource) => UnhostablePrivateSource,
+ nameof(FileLocalSource) => FileLocalSource,
+ nameof(TypeParameterReceiver) => TypeParameterReceiver,
+ nameof(TypeParameterValue) => TypeParameterValue,
+ nameof(TypeParameterResult) => TypeParameterResult,
+ nameof(TypeParameterViewBase) => TypeParameterViewBase,
+ nameof(TypeParameterEveryApi) => TypeParameterEveryApi,
+ nameof(TypeParameterInsideArray) => TypeParameterInsideArray,
+ nameof(ConstraintNamesAnotherTypeParameter) => ConstraintNamesAnotherTypeParameter,
+ nameof(UnmappableTypeParameter) => UnmappableTypeParameter,
+ nameof(HostedPrivateMember) => HostedPrivateMember,
+ nameof(UnhostablePrivateMember) => UnhostablePrivateMember,
+ nameof(ComputedPath) => ComputedPath,
+ nameof(StaticFormCall) => StaticFormCall,
+ nameof(StoredPath) => StoredPath,
+ nameof(StoredCommandParameter) => StoredCommandParameter,
+ nameof(FileLocalHost) => FileLocalHost,
+ nameof(GenericHost) => GenericHost,
+ nameof(HostedStaticFormCall) => HostedStaticFormCall,
+ nameof(HostedSchedulerOverload) => HostedSchedulerOverload,
+ _ => throw new ArgumentOutOfRangeException(nameof(name), name, "No scenario has this name."),
+ };
+
+ /// Places a scenario beside the shared view model and recorder.
+ /// The scenario's Scenario type.
+ /// The complete source.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static string Source(string scenario) => Shared.Replace("SCENARIO", scenario, StringComparison.Ordinal);
+
+ /// Runs the generator over a scenario, opted into interception or not.
+ /// The scenario's Scenario type.
+ /// Whether the build lists the generated namespace for interception.
+ /// The generator result.
+ internal static GeneratorTestResult Generate(string scenario, bool intercept)
+ {
+ var parseOptions = intercept
+ ? TestHelper.InterceptingParseOptionsFor(LanguageVersion.CSharp11)
+ : TestHelper.ParseOptionsFor(LanguageVersion.CSharp11);
+ var compilation = TestHelper.CreateCompilation(Source(scenario), parseOptions, false, "TestAssembly", []);
+
+ return TestHelper.RunGenerator(compilation, parseOptions, "TestApp", true);
+ }
+
+ /// Gets the errors of a compilation that come from generated files.
+ /// The generator result.
+ /// The errors located in generated source.
+ internal static ImmutableArray GeneratedCodeErrors(GeneratorTestResult result) =>
+ [
+ .. result.OutputCompilation.Emit(Stream.Null).Diagnostics
+ .Where(static d => d.Severity == DiagnosticSeverity.Error
+ && d.Location.SourceTree?.FilePath.EndsWith(".g.cs", StringComparison.Ordinal) == true),
+ ];
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/NoGeneratedBindingAnalyzerTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/NoGeneratedBindingAnalyzerTests.cs
index 2085c109..d19a970e 100644
--- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/NoGeneratedBindingAnalyzerTests.cs
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/NoGeneratedBindingAnalyzerTests.cs
@@ -60,7 +60,10 @@ public static void Run(Person person, Expression> stored,
public async Task GeneratedCall_IsNotReported(bool intercept) =>
await Assert.That(await AnalyzeAsync(Calls("person.WhenAnyValue(x => x.Name);"), [], intercept)).IsEmpty();
- /// A call through a member another source generator adds has no generated binding, so it is reported.
+ ///
+ /// A call through a member another source generator adds has no generated binding, so it is reported. The caller
+ /// cannot make another generator's output visible, so it stays a warning.
+ ///
/// Whether the build opts into interceptors.
/// A task representing the asynchronous test operation.
[Test]
@@ -72,6 +75,7 @@ public async Task MemberFromAnotherGenerator_IsReported(bool intercept)
await Assert.That(diagnostics.Select(static d => d.Id)).IsEquivalentTo([NoGeneratedBinding]);
await Assert.That(diagnostics[0].GetMessage()).Contains("'WhenAnyValue' has no generated binding");
+ await Assert.That(diagnostics[0].Severity).IsEqualTo(DiagnosticSeverity.Warning);
}
/// Every member ReactiveUI.SourceGenerators adds gets a generated binding, so none of its calls is reported.
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/UnnameableTypeRuntimeTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/UnnameableTypeRuntimeTests.cs
new file mode 100644
index 00000000..72581fa0
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/UnnameableTypeRuntimeTests.cs
@@ -0,0 +1,131 @@
+// 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 ReactiveUI.Binding.SourceGenerators.Helpers;
+using ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests.RuntimeExecution;
+
+///
+/// Runs call sites whose types generated code cannot name, through the generic interceptors a build against Roslyn 4.13
+/// or newer writes for them, and checks the values that arrive.
+///
+public class UnnameableTypeRuntimeTests
+{
+ /// The values a two-property projection delivers: the start, then a change to each property.
+ private static readonly int[] TwoPropertyValues = [12, 32, 34];
+
+ /// The values a one-property projection delivers: the start, then one change.
+ private static readonly int[] OnePropertyValues = [1, 3];
+
+ /// The values a tuple projection delivers: each value added to its double.
+ private static readonly int[] TupleValues = [3, 9];
+
+ /// The sums a two-property conversion delivers: the start, then one change.
+ private static readonly int[] SumValues = [3, 5];
+
+ /// The values the hosted scenario returns when every API delivered, in the order it lists them.
+ private static readonly int[] EveryApiValues = [2, 2, 5, 1, 2, 2, 7, 1, 1, 1, 4, 4];
+
+ /// A single count of one.
+ private static readonly int[] OnceValues = [1];
+
+ /// The values a combined observable delivers once both have produced a value.
+ private static readonly int[] CombinedValues = [12, 32];
+
+ /// An anonymous WhenAnyValue result arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task AnonymousWhenAnyValueResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.AnonymousWhenAnyValueResult)).IsEquivalentTo(TwoPropertyValues);
+
+ /// A private nested WhenAnyValue result arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task PrivateWhenAnyValueResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.PrivateWhenAnyValueResult)).IsEquivalentTo(OnePropertyValues);
+
+ /// A list closed over a protected nested type arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task PrivateTypeArgumentResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.PrivateTypeArgumentResult)).IsEquivalentTo(OnePropertyValues);
+
+ /// A tuple holding a private nested type arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task PrivateTupleResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.PrivateTupleResult)).IsEquivalentTo(TupleValues);
+
+ /// An anonymous WhenAny result arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task AnonymousWhenAnyResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.AnonymousWhenAnyResult)).IsEquivalentTo(OnePropertyValues);
+
+ /// An anonymous WhenChanged conversion arrives for the start and each change.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task AnonymousWhenChangedResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.AnonymousWhenChangedResult)).IsEquivalentTo(SumValues);
+
+ /// An anonymous WhenChanging conversion delivers the same values as the call with a named result.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task AnonymousWhenChangingResult_MatchesTheNamedResult()
+ {
+ var named = await RunAsync(UnnameableTypeScenarios.NamedWhenChangingResult);
+
+ await Assert.That(named).IsNotEmpty();
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.AnonymousWhenChangingResult)).IsEquivalentTo(named);
+ }
+
+ /// An anonymous WhenAnyObservable result arrives once both observables have produced a value.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task AnonymousWhenAnyObservableResult_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.AnonymousWhenAnyObservableResult)).IsEquivalentTo(CombinedValues);
+
+ /// A private nested view model observed inside its partial declaring class delivers every value.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task HostedPrivateSource_DeliversEveryValue() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.HostedPrivateSource)).IsEquivalentTo(OnePropertyValues);
+
+ /// A command bound between private nested types runs once while bound and not after the binding ends.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task HostedPrivateBindCommand_ExecutesWhileBound() =>
+ await Assert.That(await RunAsync(UnnameableTypeScenarios.HostedPrivateBindCommand)).IsEquivalentTo(OnceValues);
+
+ /// Every API, called between private nested types inside their partial declaring class, delivers its values.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task HostedEveryApi_DeliversEveryValue() =>
+ await Assert.That(string.Join(",", await RunAsync(UnnameableTypeScenarios.HostedEveryApi))).IsEqualTo(string.Join(",", EveryApiValues));
+
+ /// Generates a scenario with interception, runs it, and returns the values it recorded.
+ /// The scenario's Scenario type.
+ /// The values the call delivered.
+ private static async Task RunAsync(string scenario)
+ {
+ if (!InterceptableLocationReader.IsSupported)
+ {
+ Skip.Test("Only a build against Roslyn 4.13 or newer can intercept a call site.");
+ }
+
+ var result = UnnameableTypeScenarios.Generate(scenario, true);
+ await result.CompilationSucceeds();
+
+ var (assembly, context) = TestHelper.EmitAndLoad(result);
+ try
+ {
+ return (int[])assembly.GetType(UnnameableTypeScenarios.ScenarioTypeName)!.GetMethod("Run")!.Invoke(null, null)!;
+ }
+ finally
+ {
+ context.Unload();
+ }
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/TypeParameterCallSiteTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/TypeParameterCallSiteTests.cs
new file mode 100644
index 00000000..a2376677
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/TypeParameterCallSiteTests.cs
@@ -0,0 +1,102 @@
+// 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 ReactiveUI.Binding.SourceGenerators.Helpers;
+using ReactiveUI.Binding.SourceGenerators.Tests.Helpers;
+
+namespace ReactiveUI.Binding.SourceGenerators.Tests;
+
+///
+/// Calls built from the calling code's type parameters. A build that intercepts claims a call when each of those type
+/// parameters is one of the called method's own type arguments, with generated code generic over them. Every other
+/// such call fails the build with RXUIBIND016.
+///
+public class TypeParameterCallSiteTests
+{
+ /// Why a test only applies to a build that can intercept.
+ private const string NeedsInterception = "Only a build against Roslyn 4.13 or newer can intercept a call site.";
+
+ /// The error reported for a call built from a type parameter that no generated code claims.
+ private const string TypeParameterCallId = "RXUIBIND016";
+
+ /// The values a one-property observation delivers: the start, then one change.
+ private static readonly int[] OnePropertyValues = [1, 3];
+
+ /// The values a doubling projection delivers: the start, then one change.
+ private static readonly int[] DoubledValues = [2, 6];
+
+ /// The value a one-way binding wrote after the change.
+ private static readonly int[] BoundValues = [3];
+
+ /// The values the every-API scenario returns, in the order it lists them.
+ private static readonly int[] EveryApiValues = [2, 2, 5, 1, 2, 2, 7, 2, 1, 1];
+
+ /// Gets the scenarios a build that intercepts claims, with the values each delivers.
+ /// The scenario name and its values.
+ public static IEnumerable> Claimable()
+ {
+ yield return static () => (nameof(UnnameableTypeScenarios.TypeParameterReceiver), OnePropertyValues);
+ yield return static () => (nameof(UnnameableTypeScenarios.TypeParameterValue), OnePropertyValues);
+ yield return static () => (nameof(UnnameableTypeScenarios.TypeParameterResult), DoubledValues);
+ yield return static () => (nameof(UnnameableTypeScenarios.TypeParameterViewBase), BoundValues);
+ yield return static () => (nameof(UnnameableTypeScenarios.TypeParameterEveryApi), EveryApiValues);
+ }
+
+ /// A build that intercepts claims the call, compiles, reports nothing, and delivers every value.
+ /// The name of the scenario.
+ /// The values the scenario delivers.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [MethodDataSource(nameof(Claimable))]
+ public async Task InterceptingBuild_ClaimsAndRunsTheCall(string scenario, int[] values)
+ {
+ if (!InterceptableLocationReader.IsSupported)
+ {
+ Skip.Test(NeedsInterception);
+ }
+
+ var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), true);
+
+ await result.CompilationSucceeds();
+ await Assert.That(await CallSiteDiagnostics.ErrorsAsync(result)).IsEmpty();
+ await Assert.That(CallSiteDiagnostics.RunScenario(result)).IsEquivalentTo(values);
+ }
+
+ /// A build that does not intercept fails at the call with RXUIBIND016, and generates nothing broken.
+ /// The name of the scenario.
+ /// The values the scenario would deliver.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [MethodDataSource(nameof(Claimable))]
+ public async Task BuildWithoutInterception_FailsTheBuild(string scenario, int[] values)
+ {
+ _ = values;
+ var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), false);
+
+ await Assert.That(UnnameableTypeScenarios.GeneratedCodeErrors(result)).IsEmpty();
+ await Assert.That(await CallSiteDiagnostics.ErrorsAsync(result)).Contains(TypeParameterCallId);
+ }
+
+ ///
+ /// A type parameter the called method's type arguments do not carry, directly or through a constraint, cannot be
+ /// handed on, so every build fails.
+ ///
+ /// The name of the scenario.
+ /// Whether the build lists the generated namespace for interception.
+ /// A task representing the asynchronous test operation.
+ [Test]
+ [MatrixDataSource]
+ public async Task UnmappableTypeParameter_FailsTheBuild(
+ [Matrix(
+ nameof(UnnameableTypeScenarios.UnmappableTypeParameter),
+ nameof(UnnameableTypeScenarios.TypeParameterInsideArray),
+ nameof(UnnameableTypeScenarios.ConstraintNamesAnotherTypeParameter))] string scenario,
+ [Matrix(false, true)] bool intercept)
+ {
+ var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), intercept);
+
+ await Assert.That(UnnameableTypeScenarios.GeneratedCodeErrors(result)).IsEmpty();
+ await Assert.That(await CallSiteDiagnostics.ErrorsAsync(result)).Contains(TypeParameterCallId);
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs
new file mode 100644
index 00000000..051e20fd
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs
@@ -0,0 +1,10 @@
+//HintName: GeneratedBindingsAttributes.g.cs
+//
+#pragma warning disable
+namespace ReactiveUI.Binding.Generated.Interceptors.TestAssembly
+{
+ [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal static partial class __ReactiveUIGeneratedBindings
+ {
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs
new file mode 100644
index 00000000..ed59d9a9
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs
@@ -0,0 +1,77 @@
+//HintName: WhenAnyValueDispatch.g.cs
+//
+#pragma warning disable
+#nullable enable
+
+using System;
+
+namespace ReactiveUI.Binding.Generated.Interceptors.TestAssembly
+{
+ internal static partial class __ReactiveUIGeneratedBindings
+ {
+ [global::System.Runtime.CompilerServices.InterceptsLocation(1, "PiAfxLWrmwHb3FAJNhOn7FIMAAA=")]
+ internal static global::System.IObservable<__TResult> __Intercept_WhenAnyValue_7FFFCC69326A0F00<__T0, __T1, __T2, __TResult>(
+ this global::TestApp.Vm objectToMonitor,
+ global::System.Linq.Expressions.Expression> property1,
+ global::System.Linq.Expressions.Expression> property2,
+ global::System.Func selector,
+ [global::System.Runtime.CompilerServices.CallerArgumentExpression("property1")] string property1Expression = "",
+ [global::System.Runtime.CompilerServices.CallerArgumentExpression("property2")] string property2Expression = "",
+ [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "",
+ [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0)
+ => __WhenAnyValue_7FFFCC69326A0F00(objectToMonitor, selector);
+
+
+ private static global::System.IObservable<__TResult> __WhenAnyValue_7FFFCC69326A0F00<__TResult>(global::TestApp.Vm obj, global::System.Func selector)
+ {
+ var __propObs0Mechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable(obj, "A", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Vm)__o).A, true);
+ var __propObs0Registration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(obj.GetType(), "A", 5, false);
+ var __propObs0 = __propObs0Registration == null
+ ? (global::System.IObservable)__propObs0Mechanism
+ : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable(
+ __propObs0Registration,
+ obj,
+ ((global::System.Linq.Expressions.Expression>)(__e => __e.A)).Body,
+ "A",
+ (object __o) => ((global::TestApp.Vm)__o).A,
+ false,
+ true);
+
+ var __propObs1Mechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable(obj, "B", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Vm)__o).B, true);
+ var __propObs1Registration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(obj.GetType(), "B", 5, false);
+ var __propObs1 = __propObs1Registration == null
+ ? (global::System.IObservable)__propObs1Mechanism
+ : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable(
+ __propObs1Registration,
+ obj,
+ ((global::System.Linq.Expressions.Expression>)(__e => __e.B)).Body,
+ "B",
+ (object __o) => ((global::TestApp.Vm)__o).B,
+ false,
+ true);
+
+ return new global::ReactiveUI.Primitives.Advanced.CombineLatestSignal(
+ __propObs0,
+ __propObs1,
+ selector);
+ }
+
+ }
+}
+
+namespace System.Runtime.CompilerServices
+{
+ /// Binds a generated method to the call site it replaces.
+ [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]
+ file sealed class InterceptsLocationAttribute : global::System.Attribute
+ {
+ /// Initializes a new instance of the class.
+ /// The encoding of .
+ /// The call site being replaced.
+ public InterceptsLocationAttribute(int version, string data)
+ {
+ _ = version;
+ _ = data;
+ }
+ }
+}
diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#BindCommandDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#BindCommandDispatch.g.verified.cs
new file mode 100644
index 00000000..c72d1033
--- /dev/null
+++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#BindCommandDispatch.g.verified.cs
@@ -0,0 +1,195 @@
+//HintName: BindCommandDispatch.g.cs
+//
+#pragma warning disable
+#nullable enable
+
+using System;
+
+namespace ReactiveUI.Binding.Generated.Interceptors.TestAssembly
+{
+ internal static partial class __ReactiveUIGeneratedBindings
+ {
+ [global::System.Runtime.CompilerServices.InterceptsLocation(1, "Zs4qObJpfhfSQ83m5yXFVaIMAAA=")]
+ internal static global::System.IDisposable __InterceptHosted_7FFFDC4D3D7713C4<__S0, __S1, __S2, __S3>(
+ this __S0 view,
+ __S1? viewModel,
+ global::System.Linq.Expressions.Expression> propertyName,
+ global::System.Linq.Expressions.Expression> controlName,
+ string? toEvent,
+ string propertyNameExpression,
+ string controlNameExpression,
+ string callerFilePath,
+ int callerLineNumber)
+ where __S0 : class, global::ReactiveUI.Binding.IViewFor
+ where __S1 : class
+ where __S2 : global::System.Windows.Input.ICommand
+ where __S3 : class
+ => global::TestApp.Scenario.__ReactiveUIHostedEntries.__Entry_7FFFDC4D3D7713C4<__S0, __S1, __S2, __S3>(view, viewModel, propertyName, controlName, toEvent, propertyNameExpression, controlNameExpression, callerFilePath, callerLineNumber);
+
+ }
+}
+
+namespace TestApp
+{
+ partial class Scenario
+ {
+ internal static partial class __ReactiveUIHostedEntries
+ {
+ internal static global::System.IDisposable __Entry_7FFFDC4D3D7713C4<__S0, __S1, __S2, __S3>(
+ __S0 view,
+ __S1? viewModel,
+ global::System.Linq.Expressions.Expression> propertyName,
+ global::System.Linq.Expressions.Expression> controlName,
+ string? toEvent,
+ string propertyNameExpression,
+ string controlNameExpression,
+ string callerFilePath,
+ int callerLineNumber)
+ where __S0 : class, global::ReactiveUI.Binding.IViewFor
+ where __S1 : class
+ where __S2 : global::System.Windows.Input.ICommand
+ where __S3 : class
+ => __ReactiveUIHostedBindings.__Intercept_BindCommand_00001EF5029F9CF6((global::TestApp.Scenario.View)(object)view, (global::TestApp.Scenario.ViewModel?)(object)viewModel, (global::System.Linq.Expressions.Expression>)(object)propertyName, (global::System.Linq.Expressions.Expression>)(object)controlName, toEvent, propertyNameExpression, controlNameExpression, callerFilePath, callerLineNumber);
+
+ private static partial class __ReactiveUIHostedBindings
+ {
+ internal static global::System.IDisposable __Intercept_BindCommand_00001EF5029F9CF6(
+ global::TestApp.Scenario.View view,
+ global::TestApp.Scenario.ViewModel? viewModel,
+ global::System.Linq.Expressions.Expression> propertyName,
+ global::System.Linq.Expressions.Expression> controlName,
+ string? toEvent = null,
+ [global::System.Runtime.CompilerServices.CallerArgumentExpression("propertyName")] string propertyNameExpression = "",
+ [global::System.Runtime.CompilerServices.CallerArgumentExpression("controlName")] string controlNameExpression = "",
+ [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "",
+ [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0)
+ => __BindCommand_00001EF5029F9CF6(view, viewModel);
+
+
+ private static global::System.IDisposable __BindCommand_00001EF5029F9CF6(
+ global::TestApp.Scenario.View view,
+ global::TestApp.Scenario.ViewModel viewModel)
+ {
+ // BindCommand: Save -> SaveButton (event: Click)
+
+ var ____commandChanges_s0Mechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable(view, "ViewModel", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Scenario.View)__o).ViewModel, false);
+ var ____commandChanges_s0Registration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(view.GetType(), "ViewModel", 5, false);
+ var ____commandChanges_s0 = ____commandChanges_s0Registration == null
+ ? (global::System.IObservable)____commandChanges_s0Mechanism
+ : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable(
+ ____commandChanges_s0Registration,
+ view,
+ ((global::System.Linq.Expressions.Expression>)(__e => __e.ViewModel)).Body,
+ "ViewModel",
+ (object __o) => ((global::TestApp.Scenario.View)__o).ViewModel,
+ false,
+ true);
+ var ____commandChanges_s1 = new global::ReactiveUI.Primitives.Advanced.SwitchMapSignal(____commandChanges_s0,
+ __p1 => __p1 != null
+ ? (global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(__p1.GetType(), "Save", 5, false) is global::ReactiveUI.Binding.ICreatesObservableForProperty __registration___p1
+ ? (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable(
+ __registration___p1,
+ __p1,
+ ((global::System.Linq.Expressions.Expression>)(__e => __e.Save)).Body,
+ "Save",
+ (object __o) => ((global::TestApp.Scenario.ViewModel)__o).Save,
+ false, false)
+ : (global::System.IObservable)
+ new global::ReactiveUI.Binding.Observables.PropertyObservable(__p1, "Save", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Scenario.ViewModel)__o).Save, false))
+ : (global::System.IObservable)new global::ReactiveUI.Primitives.Advanced.ImmediateReturnSignal(default(global::System.Windows.Input.ICommand)));
+ var __commandChanges = new global::ReactiveUI.Primitives.Advanced.UniqueSignal(____commandChanges_s1, global::System.Collections.Generic.EqualityComparer.Default);
+ var commandObs = global::ReactiveUI.Binding.BindingSchedulers.ObserveOnViewThread(__commandChanges, view);
+ var __controlChangesMechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable(view, "SaveButton", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Scenario.View)__o).SaveButton, true);
+ var __controlChangesRegistration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(view.GetType(), "SaveButton", 5, false);
+ var __controlChanges = __controlChangesRegistration == null
+ ? (global::System.IObservable)__controlChangesMechanism
+ : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable(
+ __controlChangesRegistration,
+ view,
+ ((global::System.Linq.Expressions.Expression>)(__e => __e.SaveButton)).Body,
+ "SaveButton",
+ (object __o) => ((global::TestApp.Scenario.View)__o).SaveButton,
+ false,
+ true);
+ var __controls = global::ReactiveUI.Binding.BindingSchedulers.ObserveOnViewThread(__controlChanges, view);
+ var __controlBinding = new global::ReactiveUI.Primitives.Disposables.SwapDisposable();
+ var __controlSub = global::ReactiveUI.Primitives.SubscribeExtensions.Subscribe(__controls, __control =>
+ {
+ __controlBinding.Disposable = global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance;
+ if (__control != null)
+ {
+ __controlBinding.Disposable = __BindCommandCore_00001EF5029F9CF6(__control, commandObs);
+ }
+ });
+ return new global::ReactiveUI.Primitives.Disposables.MultipleDisposable(__controlSub, __controlBinding);
+ }
+
+ private static global::System.IDisposable __BindCommandCore_00001EF5029F9CF6(global::TestApp.MyButton __control, global::System.IObservable commandObs)
+ {
+
+ if (global::ReactiveUI.Binding.Fallback.CommandBindingAffinityChecker
+ .HasHigherAffinityPlugin(3, false))
+ {
+ var __customBinder = global::ReactiveUI.Binding.CommandBinding.CommandBinderService
+ .GetBinder(false);
+ if (__customBinder != null)
+ {
+ var __serial = new global::ReactiveUI.Primitives.Disposables.SwapDisposable();
+ var __binderCmdSub = global::ReactiveUI.Primitives.SubscribeExtensions.Subscribe(commandObs, __cmd =>
+ {
+ __serial.Disposable = global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance;
+ global::System.IObservable