From ab95a7bdaf8484e8f45cbaa321f7558df063b7fa Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:37:23 +1100 Subject: [PATCH] feat(generator): intercept calls whose types generated code cannot name - Intercept calls whose selector result is an anonymous or private type, by taking the result type as a generic parameter. - Intercept calls on private or protected types and members from inside the caller's partial class, by placing the generated code in a private nested class of that class. - Intercept calls made through the caller's type parameters, by generating code that is generic over them. - Fail the build at the call when it cannot be intercepted. RXUIBIND001, 003, 006, 009, 012, 013, 015, 016 and 021 are errors. Every call they report throws at run time. - Keep RXUIBIND021 a warning for a member that only another source generator declares. - Stop emitting broken code for anonymous result types, file-local types and BindCommand with a stored parameter expression. --- .../AnalyzerReleases.Unshipped.md | 18 +- .../Analyzers/AnalyzerHelpers.cs | 20 + .../Analyzers/BindingInvocationAnalyzer.cs | 18 +- .../Analyzers/NoGeneratedBindingAnalyzer.cs | 100 ++- .../Analyzers/ToPropertyAnalyzer.cs | 25 +- .../Analyzers/UnreachableTypeAnalyzer.cs | 72 +- .../AnalyzerReleases.Unshipped.md | 18 +- .../CodeGeneration/HostedCallSiteEmitter.cs | 432 ++++++++++ .../CodeGeneration/HostedSource.cs | 11 + .../CodeGeneration/HostedSourceReader.cs | 204 +++++ .../CodeGeneration/InterceptorEmitter.cs | 80 +- .../ObservationCodeGenerator.cs | 6 +- .../CodeGeneration/WhenAnyCodeGenerator.cs | 7 +- .../WhenAnyObservableCodeGenerator.cs | 10 +- .../DiagnosticWarnings.cs | 46 +- .../Helpers/BindToExtractor.cs | 9 +- .../Helpers/BindingExtractor.cs | 17 +- .../Helpers/CallSiteHosting.cs | 477 ++++++++++++ .../Helpers/CommandExtractor.cs | 69 +- .../Helpers/ExtractorValidation.cs | 101 ++- .../Helpers/InteractionExtractor.cs | 35 +- .../Helpers/InvokeCommandExtractor.cs | 9 +- .../Helpers/ObservationExtractor.cs | 77 +- .../Helpers/ReachScope.cs | 189 +++++ .../Helpers/SyntaxHelpers.cs | 38 +- .../Helpers/ToPropertyExtractor.cs | 5 +- .../Helpers/WhenAnyObservableExtractor.cs | 17 +- .../Invocations/InvocationPipeline.cs | 9 +- .../Models/BindCommandInvocationInfo.cs | 7 +- .../Models/BindInteractionInvocationInfo.cs | 7 +- .../Models/BindToInvocationInfo.cs | 7 +- .../Models/BindingInvocationInfo.cs | 7 +- .../Models/GenericResult.cs | 23 + .../Models/HostedCall.cs | 53 ++ .../Models/HostedParameter.cs | 15 + .../Models/IClaimableCallSite.cs | 15 + .../Models/InvocationInfo.cs | 12 +- .../Models/InvokeCommandInvocationInfo.cs | 7 +- .../Models/ToPropertyInvocationInfo.cs | 7 +- .../Models/WhenAnyObservableInvocationInfo.cs | 12 +- .../DiagnosticSeverityTests.cs | 62 ++ .../ToPropertyAnalyzerTests.cs | 12 + .../UnreachableTypeAnalyzerTests.cs | 109 +++ .../HostedCallSiteEmitterTests.cs | 106 +++ .../CodeGeneration/HostedSourceReaderTests.cs | 85 ++ .../Helpers/CallSiteDiagnostics.cs | 52 ++ .../Helpers/CallSiteHostingTests.cs | 115 +++ .../Helpers/CommandExtractorHelperTests.cs | 2 + .../ExtractorValidationTests.NamelessTypes.cs | 201 +++++ .../Helpers/ExtractorValidationTests.cs | 2 +- .../UnnameableTypeScenarios.TypeParameters.cs | 406 ++++++++++ ...UnnameableTypeScenarios.UnreadablePaths.cs | 134 ++++ .../Helpers/UnnameableTypeScenarios.cs | 736 ++++++++++++++++++ .../NoGeneratedBindingAnalyzerTests.cs | 6 +- .../UnnameableTypeRuntimeTests.cs | 131 ++++ .../TypeParameterCallSiteTests.cs | 102 +++ ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...eResult#WhenAnyValueDispatch.g.verified.cs | 77 ++ ...dCommand#BindCommandDispatch.g.verified.cs | 195 +++++ ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...eSource#WhenAnyValueDispatch.g.verified.cs | 85 ++ ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...eResult#WhenAnyValueDispatch.g.verified.cs | 55 ++ ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...rViewBase#OneWayBindDispatch.g.verified.cs | 207 +++++ ...rameterViewBase#ViewDispatch.g.verified.cs | 65 ++ .../UnclaimedCallSiteTests.cs | 83 ++ .../UnnameableTypeCallSiteTests.cs | 260 +++++++ .../UnreachableTypeInvocationTests.cs | 6 +- 70 files changed, 5438 insertions(+), 197 deletions(-) create mode 100644 src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedCallSiteEmitter.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSource.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/HostedSourceReader.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Helpers/CallSiteHosting.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Helpers/ReachScope.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Models/GenericResult.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Models/HostedCall.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Models/HostedParameter.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Models/IClaimableCallSite.cs create mode 100644 src/tests/ReactiveUI.Binding.Analyzer.Tests/DiagnosticSeverityTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedCallSiteEmitterTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/CodeGeneration/HostedSourceReaderTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteDiagnostics.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/CallSiteHostingTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ExtractorValidationTests.NamelessTypes.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.TypeParameters.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.UnreadablePaths.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/UnnameableTypeScenarios.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/UnnameableTypeRuntimeTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/TypeParameterCallSiteTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.AnonymousWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#BindCommandDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateBindCommand#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.HostedPrivateSource#WhenAnyValueDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.PrivateWhenAnyValueResult#WhenAnyValueDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#OneWayBindDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UTCS.TypeParameterViewBase#ViewDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnclaimedCallSiteTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/UnnameableTypeCallSiteTests.cs 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 {