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 __paramObs = global::ReactiveUI.Primitives.Advanced.ImmutableEmptySignal.Instance; + __serial.Disposable = __customBinder.BindCommandToObject( + __cmd, __control, __paramObs) + ?? global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance; + }); + return new global::ReactiveUI.Primitives.Disposables.MultipleDisposable(__binderCmdSub, __serial); + } + } + + + var serial = new global::ReactiveUI.Primitives.Disposables.SwapDisposable(); + var __cmdSub = global::ReactiveUI.Primitives.SubscribeExtensions.Subscribe(commandObs, (global::System.Windows.Input.ICommand cmd) => + { + serial.Disposable = global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance; + if (cmd == null) + { + return; + } + + void __Handler(object? sender, global::System.EventArgs e) + { + if (cmd.CanExecute(null)) + { + cmd.Execute(null); + } + } + + __control.Click += __Handler; + serial.Disposable = new global::ReactiveUI.Primitives.Disposables.ActionDisposable(() => + __control.Click -= __Handler); + }); + return new global::ReactiveUI.Primitives.Disposables.MultipleDisposable(__cmdSub, serial); + } + + } + } + } +} + +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#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..051e20fd --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#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.HostedPrivateSource#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..051e20fd --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#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.HostedPrivateSource#WhenAnyValueDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#WhenAnyValueDispatch.g.verified.cs new file mode 100644 index 00000000..25f51d8f --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#WhenAnyValueDispatch.g.verified.cs @@ -0,0 +1,85 @@ +//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, "OVSZ7WYLkbF8UkNM4NGmr1YMAAA=")] + internal static global::System.IObservable<__S1> __InterceptHosted_7FFFDDFF1C7056F3<__S0, __S1>( + this __S0 sender, + global::System.Linq.Expressions.Expression> property1, + string property1Expression, + string callerFilePath, + int callerLineNumber) + where __S0 : class + => global::TestApp.Scenario.__ReactiveUIHostedEntries.__Entry_7FFFDDFF1C7056F3<__S0, __S1>(sender, property1, property1Expression, callerFilePath, callerLineNumber); + + } +} + +namespace TestApp +{ + partial class Scenario + { + internal static partial class __ReactiveUIHostedEntries + { + internal static global::System.IObservable<__S1> __Entry_7FFFDDFF1C7056F3<__S0, __S1>( + __S0 sender, + global::System.Linq.Expressions.Expression> property1, + string property1Expression, + string callerFilePath, + int callerLineNumber) + where __S0 : class + => (global::System.IObservable<__S1>)(object)__ReactiveUIHostedBindings.__Intercept_WhenAnyValue_000001A72913D988((global::TestApp.Scenario.Inner)(object)sender, (global::System.Linq.Expressions.Expression>)(object)property1, property1Expression, callerFilePath, callerLineNumber); + + private static partial class __ReactiveUIHostedBindings + { + internal static global::System.IObservable __Intercept_WhenAnyValue_000001A72913D988( + global::TestApp.Scenario.Inner objectToMonitor, + global::System.Linq.Expressions.Expression> property1, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("property1")] string property1Expression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + => __WhenAnyValue_000001A72913D988(objectToMonitor); + + + private static global::System.IObservable __WhenAnyValue_000001A72913D988(global::TestApp.Scenario.Inner obj) + { + return (global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(obj.GetType(), "A", 5, false) is global::ReactiveUI.Binding.ICreatesObservableForProperty __registration_obj + ? (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable( + __registration_obj, + obj, + ((global::System.Linq.Expressions.Expression>)(__e => __e.A)).Body, + "A", + (object __o) => ((global::TestApp.Scenario.Inner)__o).A, + false, false) + : (global::System.IObservable) + new global::ReactiveUI.Binding.Observables.PropertyObservable(obj, "A", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Scenario.Inner)__o).A, true)); + } + + } + } + } +} + +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.PrivateWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..051e20fd --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#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.PrivateWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs new file mode 100644 index 00000000..d510936c --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs @@ -0,0 +1,55 @@ +//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, "8gzjAnTPpeioOddJ2diTiksMAAA=")] + internal static global::System.IObservable<__TResult> __Intercept_WhenAnyValue_7FFFCC68B95E80F9<__T0, __T1, __TResult>( + this global::TestApp.Vm objectToMonitor, + global::System.Linq.Expressions.Expression> property1, + global::System.Func selector, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("property1")] string property1Expression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + => __WhenAnyValue_7FFFCC68B95E80F9(objectToMonitor, selector); + + + private static global::System.IObservable<__TResult> __WhenAnyValue_7FFFCC68B95E80F9<__TResult>(global::TestApp.Vm obj, global::System.Func selector) + { + return new global::ReactiveUI.Primitives.Signals.MapSignal((global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(obj.GetType(), "A", 5, false) is global::ReactiveUI.Binding.ICreatesObservableForProperty __registration_obj + ? (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable( + __registration_obj, + obj, + ((global::System.Linq.Expressions.Expression>)(__e => __e.A)).Body, + "A", + (object __o) => ((global::TestApp.Vm)__o).A, + false, false) + : (global::System.IObservable) + new global::ReactiveUI.Binding.Observables.PropertyObservable(obj, "A", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.Vm)__o).A, true)), 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.TypeParameterViewBase#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..051e20fd --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#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.TypeParameterViewBase#OneWayBindDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#OneWayBindDispatch.g.verified.cs new file mode 100644 index 00000000..7b429888 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#OneWayBindDispatch.g.verified.cs @@ -0,0 +1,207 @@ +//HintName: OneWayBindDispatch.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, "s7sD/tQJ3xEt3L7BnYDcIwIQAAA=")] + internal static global::ReactiveUI.Binding.IReactiveBinding<__S1, __S3> __InterceptHosted_7FFFC6E18C012F99<__S0, __S1, __S2, __S3>( + this __S1 view, + __S0? viewModel, + global::System.Linq.Expressions.Expression> viewModelProperty, + global::System.Linq.Expressions.Expression> viewProperty, + string viewModelPropertyExpression, + string viewPropertyExpression, + string callerFilePath, + int callerLineNumber) + where __S0 : class, global::TestApp.IHasA + where __S1 : class, global::ReactiveUI.Binding.IViewFor + => global::ReactiveUI.Binding.Generated.Interceptors.TestAssembly.__ReactiveUIGeneratedBindings.__ReactiveUIHostedEntries_00003708F4D34315<__S0>.__Entry_7FFFC6E18C012F99<__S0, __S1, __S2, __S3>(view, viewModel, viewModelProperty, viewProperty, viewModelPropertyExpression, viewPropertyExpression, callerFilePath, callerLineNumber); + + } +} + +namespace ReactiveUI.Binding.Generated.Interceptors.TestAssembly +{ + static partial class __ReactiveUIGeneratedBindings + { + internal static partial class __ReactiveUIHostedEntries_00003708F4D34315 + where TViewModel : class, global::TestApp.IHasA + { + internal static global::ReactiveUI.Binding.IReactiveBinding<__S1, __S3> __Entry_7FFFC6E18C012F99<__S0, __S1, __S2, __S3>( + __S1 view, + __S0? viewModel, + global::System.Linq.Expressions.Expression> viewModelProperty, + global::System.Linq.Expressions.Expression> viewProperty, + string viewModelPropertyExpression, + string viewPropertyExpression, + string callerFilePath, + int callerLineNumber) + where __S0 : class, global::TestApp.IHasA + where __S1 : class, global::ReactiveUI.Binding.IViewFor + => (global::ReactiveUI.Binding.IReactiveBinding<__S1, __S3>)(object)__ReactiveUIHostedBindings.__Intercept_OneWayBind_00001DF1662723E5((global::TestApp.ViewBase)(object)view, (TViewModel?)(object)viewModel, (global::System.Linq.Expressions.Expression>)(object)viewModelProperty, (global::System.Linq.Expressions.Expression, int>>)(object)viewProperty, viewModelPropertyExpression, viewPropertyExpression, callerFilePath, callerLineNumber); + + private static partial class __ReactiveUIHostedBindings + { + internal static global::ReactiveUI.Binding.IReactiveBinding, int> __Intercept_OneWayBind_00001DF1662723E5( + global::TestApp.ViewBase view, + TViewModel? viewModel, + global::System.Linq.Expressions.Expression> viewModelProperty, + global::System.Linq.Expressions.Expression, int>> viewProperty, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("viewModelProperty")] string viewModelPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerArgumentExpression("viewProperty")] string viewPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + => __OneWayBind_00001DF1662723E5(viewModel, view); + + + private static global::ReactiveUI.Binding.IReactiveBinding, int> __OneWayBind_00001DF1662723E5(TViewModel viewModel, global::TestApp.ViewBase view) + { + // OneWayBind: A -> Shown + if (global::ReactiveUI.Binding.BindingHooks.Any) + { + var __hookSource = viewModel; + var __hookTarget = view; + if (!global::ReactiveUI.Binding.BindingHooks.ShouldBind( + __hookSource, + __hookTarget, + () => + { + var __hookChanges = new global::System.Collections.Generic.List>(1); + var __hookOwner0 = __hookSource; + if (__hookOwner0 is null) + { + return __hookChanges.ToArray(); + } + var __hookValue0 = __hookOwner0.A; + __hookChanges.Add(new global::ReactiveUI.Binding.ObservedChange(__hookOwner0, ((global::System.Linq.Expressions.Expression>)(__property => __property.A)).Body, __hookValue0)); + return __hookChanges.ToArray(); + }, + () => + { + var __hookChanges = new global::System.Collections.Generic.List>(1); + var __hookOwner0 = __hookTarget; + if (__hookOwner0 is null) + { + return __hookChanges.ToArray(); + } + var __hookValue0 = __hookOwner0.Shown; + __hookChanges.Add(new global::ReactiveUI.Binding.ObservedChange(__hookOwner0, ((global::System.Linq.Expressions.Expression, int>>)(__property => __property.Shown)).Body, __hookValue0)); + return __hookChanges.ToArray(); + }, + global::ReactiveUI.Binding.BindingDirection.OneWay)) + { + return null; + } + } + var __sourceObs_s0Mechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable(view, "ViewModel", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.ViewBase)__o).ViewModel, false); + var __sourceObs_s0Registration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(view.GetType(), "ViewModel", 5, false); + var __sourceObs_s0 = __sourceObs_s0Registration == null + ? (global::System.IObservable)__sourceObs_s0Mechanism + : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable( + __sourceObs_s0Registration, + view, + ((global::System.Linq.Expressions.Expression, TViewModel>>)(__e => __e.ViewModel)).Body, + "ViewModel", + (object __o) => ((global::TestApp.ViewBase)__o).ViewModel, + false, + true); + var __sourceObs_s1 = new global::ReactiveUI.Primitives.Advanced.SwitchMapSignal(__sourceObs_s0, + __p1 => __p1 != null + ? (global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(__p1.GetType(), "A", 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.A)).Body, + "A", + (object __o) => ((global::TestApp.IHasA)__o).A, + false, false) + : (global::System.IObservable) + new global::ReactiveUI.Binding.Observables.PropertyObservable(__p1, "A", (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.IHasA)__o).A, false)) + : (global::System.IObservable)global::ReactiveUI.Primitives.Advanced.ImmutableEmptySignal.Instance); + var sourceObs = new global::ReactiveUI.Primitives.Advanced.UniqueSignal(__sourceObs_s1, global::System.Collections.Generic.EqualityComparer.Default); + global::ReactiveUI.Binding.IBindingTypeConverter __convertedForwardConverter = null; + if (__convertedForwardConverter == null) + { + __convertedForwardConverter = global::ReactiveUI.Binding.BindingConverters.Current.TypedConverters.TryGetConverter(typeof(int), typeof(int)); + if (__convertedForwardConverter != null && __convertedForwardConverter.GetAffinityForObjects() <= 1) + { + __convertedForwardConverter = null; + } + } + var __convertedForward = __convertedForwardConverter == null ? (global::System.IObservable)sourceObs : global::ReactiveUI.Primitives.LinqExtensions.Choose( + sourceObs, + __value => + { + object __hint = null; + if (__convertedForwardConverter != null) + { + if (__convertedForwardConverter is global::ReactiveUI.Binding.IBindingTypeConverter __typed) + { + int __converted; + if (__typed.TryConvert(__value, __hint, out __converted)) + { + return (true, __converted); + } + } + else + { + object __boxed; + if (__convertedForwardConverter.TryConvertTyped(__value, __hint, out __boxed)) + { + return (true, (int)__boxed); + } + } + if (null == null) + { + return (true, (int)__value); + } + return (false, default(int)); + } + return true ? (true, (int)__value) : (true, (int)__value); + }); + var viewThreadObs = global::ReactiveUI.Binding.BindingSchedulers.ObserveOnViewThread(__convertedForward, view); + + var sub = global::ReactiveUI.Binding.BindingErrors.Subscribe(viewThreadObs, value => + { + if (global::System.Collections.Generic.EqualityComparer.Default.Equals(view.Shown, value)) + { + return; + } + + view.Shown = value; + }, "v => v.Shown"); + + return new global::ReactiveUI.Binding.ReactiveBinding, int>( + view, + viewThreadObs, + global::ReactiveUI.Binding.BindingDirection.OneWay, + sub); + } + + } + } + } +} + +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.TypeParameterViewBase#ViewDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#ViewDispatch.g.verified.cs new file mode 100644 index 00000000..ad7c52d4 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#ViewDispatch.g.verified.cs @@ -0,0 +1,65 @@ +//HintName: ViewDispatch.g.cs +// +#pragma warning disable +#nullable enable + +namespace ReactiveUI.Binding.Generated.Interceptors.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Registers the source-generated view dispatch function with + /// when the module loads. + /// + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void __RegisterViewDispatch() + { + global::ReactiveUI.Binding.DefaultViewLocator.SetGeneratedViewDispatch( + __TryResolveView); + } + + /// + /// Compile-time generated type-switch dispatch for view resolution. + /// Attempts to resolve a view for the given view model instance without reflection. + /// + /// The view model instance to resolve a view for. + /// The contract string (empty string for default). + /// The resolved view, or if no generated mapping exists. + private static global::ReactiveUI.Binding.IViewFor? __TryResolveView( + object instance, string contract) + { + // global::TestApp.HasA -> global::TestApp.HasAView + if (instance is global::TestApp.HasA && contract.Length == 0) + { + return __ResolveView_0(contract); + } + + // No compile-time mapping found; fall back to runtime resolution. + return null; + } + + /// + /// Resolves a view for . + /// Tries the service locator first, then falls back to direct construction. + /// + /// The contract string (empty string for default). + /// The resolved view, or if resolution fails. + private static global::ReactiveUI.Binding.IViewFor? __ResolveView_0(string contract) + { + // Normalize contract: empty string means no contract (null for Splat lookup). + string? svcContract = contract.Length == 0 ? null : contract; + + // Prefer service-locator-registered view (supports DI-configured instances). + var view = global::Splat.AppLocator.Current + .GetService>( + svcContract); + if (view != null) + { + return view; + } + + // Fallback: direct construction (global::TestApp.HasAView has a parameterless constructor). + return new global::TestApp.HasAView(); + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnclaimedCallSiteTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnclaimedCallSiteTests.cs new file mode 100644 index 00000000..d06da68e --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnclaimedCallSiteTests.cs @@ -0,0 +1,83 @@ +// 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; + +/// +/// A binding call that no generated code claims would throw when it runs, so the build fails at the call instead. A +/// call a build can claim with an interceptor is claimed, and reports nothing. +/// +public class UnclaimedCallSiteTests +{ + /// 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 values a one-property observation delivers: the start, then one change. + private static readonly int[] OnePropertyValues = [1, 3]; + + /// A call no build can claim fails the build with the error that names its cause. + /// The name of the scenario. + /// The error the call reports. + /// Whether the build lists the generated namespace for interception. + /// A task representing the asynchronous test operation. + [Test] + [Arguments(nameof(UnnameableTypeScenarios.UnhostablePrivateMember), "RXUIBIND003", false)] + [Arguments(nameof(UnnameableTypeScenarios.UnhostablePrivateMember), "RXUIBIND003", true)] + [Arguments(nameof(UnnameableTypeScenarios.ComputedPath), "RXUIBIND021", false)] + [Arguments(nameof(UnnameableTypeScenarios.ComputedPath), "RXUIBIND021", true)] + [Arguments(nameof(UnnameableTypeScenarios.StaticFormCall), "RXUIBIND021", false)] + [Arguments(nameof(UnnameableTypeScenarios.StaticFormCall), "RXUIBIND021", true)] + [Arguments(nameof(UnnameableTypeScenarios.StoredPath), "RXUIBIND001", false)] + [Arguments(nameof(UnnameableTypeScenarios.StoredPath), "RXUIBIND001", true)] + [Arguments(nameof(UnnameableTypeScenarios.StoredCommandParameter), "RXUIBIND001", false)] + [Arguments(nameof(UnnameableTypeScenarios.StoredCommandParameter), "RXUIBIND001", true)] + public async Task UnclaimableCall_FailsTheBuild(string scenario, string expectedId, bool intercept) + { + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), intercept); + var errors = await CallSiteDiagnostics.ErrorsAsync(result); + + await Assert.That(UnnameableTypeScenarios.GeneratedCodeErrors(result)).IsEmpty(); + await Assert.That(errors).Contains(expectedId); + } + + /// A private property read inside its partial declaring class is claimed, and reports nothing. + /// A task representing the asynchronous test operation. + [Test] + public async Task HostedPrivateMember_ReportsNothing() + { + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.HostedPrivateMember, true); + + await Assert.That(await CallSiteDiagnostics.ErrorsAsync(result)).IsEmpty(); + } + + /// A private property read inside its partial declaring class delivers every value. + /// A task representing the asynchronous test operation. + [Test] + public async Task HostedPrivateMember_DeliversEveryValue() + { + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.HostedPrivateMember, true); + await result.CompilationSucceeds(); + await Assert.That(CallSiteDiagnostics.RunScenario(result)).IsEquivalentTo(OnePropertyValues); + } + + /// A build that cannot intercept reports the private property as an error. + /// A task representing the asynchronous test operation. + [Test] + public async Task HostedPrivateMember_WithoutInterception_FailsTheBuild() => + await Assert.That(await CallSiteDiagnostics.ErrorsAsync(UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.HostedPrivateMember, false))) + .Contains("RXUIBIND003"); +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnnameableTypeCallSiteTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnnameableTypeCallSiteTests.cs new file mode 100644 index 00000000..cb8a748b --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnnameableTypeCallSiteTests.cs @@ -0,0 +1,260 @@ +// 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 System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ReactiveUI.Binding.SourceGenerators.Helpers; +using ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +namespace ReactiveUI.Binding.SourceGenerators.Tests; + +/// +/// Call sites that name a type generated code cannot name: an anonymous type, a private or protected nested type, or a +/// type built from one. A build that intercepts claims them with generic interceptors. Every other build fails at the +/// call with RXUIBIND015 as an error, and no generated file names the type. +/// +public class UnnameableTypeCallSiteTests +{ + /// The interception attribute a claimed call site carries. + private const string InterceptsAttribute = "InterceptsLocation("; + + /// The dispatch file WhenAnyValue generates into. + private const string WhenAnyValueDispatch = "WhenAnyValueDispatch.g.cs"; + + /// 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."; + + /// Gets every scenario, with the dispatch file its API generates into. + /// The scenario name and the dispatch file name. + public static IEnumerable> Scenarios() + { + yield return static () => (nameof(UnnameableTypeScenarios.AnonymousWhenAnyValueResult), WhenAnyValueDispatch); + yield return static () => (nameof(UnnameableTypeScenarios.PrivateWhenAnyValueResult), WhenAnyValueDispatch); + yield return static () => (nameof(UnnameableTypeScenarios.PrivateTypeArgumentResult), WhenAnyValueDispatch); + yield return static () => (nameof(UnnameableTypeScenarios.PrivateTupleResult), WhenAnyValueDispatch); + yield return static () => (nameof(UnnameableTypeScenarios.AnonymousWhenAnyResult), "WhenAnyDispatch.g.cs"); + yield return static () => (nameof(UnnameableTypeScenarios.AnonymousWhenChangedResult), "WhenChangedDispatch.g.cs"); + yield return static () => (nameof(UnnameableTypeScenarios.AnonymousWhenChangingResult), "WhenChangingDispatch.g.cs"); + yield return static () => (nameof(UnnameableTypeScenarios.AnonymousWhenAnyObservableResult), "WhenAnyObservableDispatch.g.cs"); + yield return static () => (nameof(UnnameableTypeScenarios.HostedPrivateSource), WhenAnyValueDispatch); + yield return static () => (nameof(UnnameableTypeScenarios.HostedPrivateBindCommand), "BindCommandDispatch.g.cs"); + yield return static () => (nameof(UnnameableTypeScenarios.HostedEveryApi), "ToPropertyDispatch.g.cs"); + } + + /// Gets the dispatch file of every API the hosted scenario calls. + /// The dispatch file names. + public static IEnumerable HostedApiDispatchFiles() + { + yield return "WhenChangedDispatch.g.cs"; + yield return "WhenChangingDispatch.g.cs"; + yield return WhenAnyValueDispatch; + yield return "WhenAnyDispatch.g.cs"; + yield return "WhenAnyObservableDispatch.g.cs"; + yield return "BindOneWayDispatch.g.cs"; + yield return "BindTwoWayDispatch.g.cs"; + yield return "OneWayBindDispatch.g.cs"; + yield return "BindDispatch.g.cs"; + yield return "BindToDispatch.g.cs"; + yield return "InvokeCommandDispatch.g.cs"; + yield return "BindInteractionDispatch.g.cs"; + yield return "ToPropertyDispatch.g.cs"; + } + + /// Every API claims its call between private nested types from the caller's partial class. + /// The dispatch file of one API. + /// A task representing the asynchronous test operation. + [Test] + [MethodDataSource(nameof(HostedApiDispatchFiles))] + public async Task InterceptingBuild_ClaimsEveryHostedApi(string dispatchFile) + { + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.HostedEveryApi, true); + + await result.GeneratedSourceContains(dispatchFile, InterceptsAttribute); + await result.GeneratedSourceContains(dispatchFile, "private static partial class __ReactiveUIHostedBindings"); + } + + /// + /// Calls no build can claim: a private type whose declaring class is not partial, a file-local type, a private type + /// in a file-local class, a call written through the stub's declaring class, and a scheduler overload declared in + /// an extension block. + /// + /// The name of the scenario. + /// Whether the build lists the generated namespace for interception. + /// A task representing the asynchronous test operation. + [Test] + [MatrixDataSource] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task UnclaimableCall_ReportsTheTypeAsAnError( + [Matrix( + nameof(UnnameableTypeScenarios.UnhostablePrivateSource), + nameof(UnnameableTypeScenarios.FileLocalSource), + nameof(UnnameableTypeScenarios.FileLocalHost), + nameof(UnnameableTypeScenarios.HostedStaticFormCall), + nameof(UnnameableTypeScenarios.HostedSchedulerOverload))] string scenario, + [Matrix(false, true)] bool intercept) => + AssertReportedAsError(UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), intercept)); + + /// + /// A private type nested in a generic class is built from that class's type parameter, so no build hosts the call. + /// RXUIBIND016 reports it, and no generated file names the type. + /// + /// Whether the build lists the generated namespace for interception. + /// A task representing the asynchronous test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task PrivateTypeInAGenericClass_ReportsATypeParameterCall(bool intercept) + { + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.GenericHost, intercept); + var diagnostics = await result.OutputCompilation + .WithAnalyzers([new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.UnreachableTypeAnalyzer()]) + .GetAnalyzerDiagnosticsAsync(); + + await Assert.That(UnnameableTypeScenarios.GeneratedCodeErrors(result)).IsEmpty(); + await Assert.That(diagnostics.Select(static d => d.Id)).IsEquivalentTo(["RXUIBIND016"]); + } + + /// A build that intercepts claims the call site, and the output compiles and emits. + /// The name of the scenario. + /// The dispatch file the call site's API generates into. + /// A task representing the asynchronous test operation. + [Test] + [MethodDataSource(nameof(Scenarios))] + public async Task InterceptingBuild_ClaimsTheCallSite(string scenario, string dispatchFile) + { + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), true); + var emitErrors = result.OutputCompilation.Emit(Stream.Null).Diagnostics + .Where(static d => d.Severity == DiagnosticSeverity.Error) + .Select(static d => $"{d.Id}: {d.GetMessage()}") + .ToArray(); + + await result.CompilationSucceeds(); + await Assert.That(emitErrors).IsEmpty(); + await result.GeneratedSourceContains(dispatchFile, InterceptsAttribute); + } + + /// A call site an interceptor claims reports neither RXUIBIND015 nor RXUIBIND021. + /// The name of the scenario. + /// The dispatch file the call site's API generates into. + /// A task representing the asynchronous test operation. + [Test] + [MethodDataSource(nameof(Scenarios))] + public async Task InterceptingBuild_ReportsNothing(string scenario, string dispatchFile) + { + _ = dispatchFile; + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var diagnostics = await AnalyzeAsync(UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), true)); + + await Assert.That(diagnostics.Select(static d => d.Id)).IsEmpty(); + } + + /// + /// A build that does not intercept fails at the call with RXUIBIND015 as an error, and generates nothing that names + /// the type. + /// + /// The name of the scenario. + /// The dispatch file the call site's API generates into. + /// A task representing the asynchronous test operation. + [Test] + [MethodDataSource(nameof(Scenarios))] + public async Task BuildWithoutInterception_ReportsTheTypeAsAnError(string scenario, string dispatchFile) + { + _ = dispatchFile; + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), false); + + await AssertReportedAsError(result); + } + + /// The baseline build cannot intercept even when the project opts in, so it reports the call as an error. + /// The name of the scenario. + /// The dispatch file the call site's API generates into. + /// A task representing the asynchronous test operation. + [Test] + [MethodDataSource(nameof(Scenarios))] + public async Task BaselineBuild_OptedIn_ReportsTheTypeAsAnError(string scenario, string dispatchFile) + { + _ = dispatchFile; + if (InterceptableLocationReader.IsSupported) + { + Skip.Test("A build against Roslyn 4.13 or newer intercepts the call site instead."); + } + + await AssertReportedAsError(UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), true)); + } + + /// + /// The code written for a call site matches its snapshot: a generic interceptor for an anonymous or private result, + /// code moved into the caller's partial class for a private view model, and code generic over the caller's type + /// parameters for a generic view base. + /// + /// The name of the scenario. + /// A task representing the asynchronous test operation. + [Test] + [Arguments(nameof(UnnameableTypeScenarios.AnonymousWhenAnyValueResult))] + [Arguments(nameof(UnnameableTypeScenarios.PrivateWhenAnyValueResult))] + [Arguments(nameof(UnnameableTypeScenarios.HostedPrivateSource))] + [Arguments(nameof(UnnameableTypeScenarios.HostedPrivateBindCommand))] + [Arguments(nameof(UnnameableTypeScenarios.TypeParameterViewBase))] + public async Task InterceptingBuild_MatchesTheSnapshot(string scenario) + { + if (!InterceptableLocationReader.IsSupported) + { + Skip.Test(NeedsInterception); + } + + var result = UnnameableTypeScenarios.Generate(UnnameableTypeScenarios.Named(scenario), true); + + await GeneratorSnapshot.VerifyAsync(result.Driver, "UTCS", scenario); + } + + /// Asserts that every call is reported as an RXUIBIND015 error and that no generated file fails to compile. + /// The generator result. + /// A task representing the asynchronous assertion. + private static async Task AssertReportedAsError(GeneratorTestResult result) + { + var diagnostics = await AnalyzeAsync(result); + + await Assert.That(UnnameableTypeScenarios.GeneratedCodeErrors(result)).IsEmpty(); + await Assert.That(diagnostics).IsNotEmpty(); + await Assert.That(diagnostics.Select(static d => $"{d.Id} {d.Severity}").Distinct()) + .IsEquivalentTo([$"{UnnameableTypeScenarios.UnreachableTypeId} {DiagnosticSeverity.Error}"]); + } + + /// Runs the analyzers that report an ungenerated call over what the generator produced. + /// The generator result. + /// The RXUIBIND015 and RXUIBIND021 diagnostics. + private static async Task> AnalyzeAsync(GeneratorTestResult result) + { + var diagnostics = await result.OutputCompilation + .WithAnalyzers( + [ + new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.UnreachableTypeAnalyzer(), + new analyzer::ReactiveUI.Binding.Analyzer.Analyzers.NoGeneratedBindingAnalyzer(), + ]) + .GetAnalyzerDiagnosticsAsync(); + + return + [ + .. diagnostics.Where(static d => d.Id is UnnameableTypeScenarios.UnreachableTypeId or UnnameableTypeScenarios.NoGeneratedBindingId), + ]; + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnreachableTypeInvocationTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnreachableTypeInvocationTests.cs index 6ffe0167..0f224e15 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnreachableTypeInvocationTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnreachableTypeInvocationTests.cs @@ -7,9 +7,9 @@ namespace ReactiveUI.Binding.SourceGenerators.Tests; /// -/// Call sites that name a private or protected nested type. Generated code lives in a class of its own and cannot -/// name such a type, so the generator leaves the call on the runtime stub rather than emit code that fails the -/// consumer's build. +/// Call sites that name a private or protected nested type, in a build that writes dispatch overloads. Generated code +/// lives in a class of its own and cannot name such a type, so the generator writes nothing for the call rather than +/// code that fails the consumer's build. RXUIBIND015 then fails the build at the call. /// public class UnreachableTypeInvocationTests {