Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -382,6 +383,25 @@ internal static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol
return false;
}

/// <summary>Determines whether a generated interceptor claims a binding call.</summary>
/// <param name="invocation">The call.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when an interceptor replaces the call.</returns>
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
}

/// <summary>Checks if a method is an API or its <c>Unsafe</c> twin.</summary>
/// <param name="methodName">The name of the method.</param>
/// <param name="apiName">The name of the API.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,11 @@ namespace ReactiveUI.Binding.Analyzer.Analyzers;
/// adds, which the generator cannot see.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
Expand Down Expand Up @@ -55,17 +61,75 @@ 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;
}

context.ReportDiagnostic(Diagnostic.Create(
DiagnosticWarnings.NoGeneratedBinding,
invocation.Syntax.GetLocation(),
NamesAnotherGeneratorsMember(invocation, context.CancellationToken) ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
null,
null,
method.Name));
}

/// <summary>Determines whether a call's lambdas name a member that only generated code declares.</summary>
/// <param name="invocation">The call.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when some member the call names is declared only in generated files.</returns>
/// <remarks>
/// 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.
/// </remarks>
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;
}

/// <summary>Determines whether every declaration of a member sits in a generated file.</summary>
/// <param name="member">The member.</param>
/// <returns><see langword="true"/> when the member has declarations and all of them are generated.</returns>
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;
}

/// <summary>Determines whether a method is declared by a runtime stub class, whose binding methods throw.</summary>
/// <param name="containingType">The type declaring the method.</param>
/// <returns><see langword="true"/> for the stub classes, directly or through an extension block's grouping type.</returns>
Expand Down Expand Up @@ -108,25 +172,19 @@ private static bool Throws(IMethodSymbol method)
private static bool IsStubName(string name) =>
name is Constants.StubExtensionClassName or Constants.SchedulerExtensionClassName;

/// <summary>Determines whether a generated interceptor claims the call.</summary>
/// <summary>Determines whether RXUIBIND015 or RXUIBIND016 already reports the call, which names a type generated code cannot name.</summary>
/// <param name="invocation">The call.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when an interceptor replaces the call.</returns>
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
}
/// <param name="context">The operation analysis context.</param>
/// <returns><see langword="true"/> when the call names an anonymous, private, protected or type-parameter type.</returns>
/// <remarks>That error already fails the build at the call, so a second report of the same call adds nothing.</remarks>
private static bool IsReportedAsUnreachable(IInvocationOperation invocation, in OperationAnalysisContext context) =>
UnreachableTypeAnalyzer.FindUnreachable(invocation, in context) is not null;

/// <summary>Determines whether a file is generated, by its name or its auto-generated marker.</summary>
/// <param name="tree">The file.</param>
/// <returns><see langword="true"/> for a generated file.</returns>
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("<auto-generated", StringComparison.Ordinal) >= 0;
}
25 changes: 13 additions & 12 deletions src/ReactiveUI.Binding.Analyzer/Analyzers/ToPropertyAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,23 @@ 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;
}

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;
Expand All @@ -85,7 +87,7 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
/// <param name="property">The property argument.</param>
/// <returns><see langword="true"/> for <c>x =&gt; x.Property</c> or a constant, non-blank string.</returns>
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);

Expand Down Expand Up @@ -114,17 +116,16 @@ internal static bool IsDirectMemberSelector(SyntaxNode syntax)
/// <summary>Finds the argument passed for a parameter, whatever position or name it was passed by.</summary>
/// <param name="arguments">The invocation's arguments.</param>
/// <param name="parameterName">The parameter name.</param>
/// <returns>The argument, or null when the overload has no such parameter.</returns>
private static IArgumentOperation? FindArgument(ImmutableArray<IArgumentOperation> arguments, string parameterName)
/// <returns>The argument.</returns>
/// <remarks>Every <c>ToProperty</c> overload takes both the source and the property, so the argument is always there.</remarks>
private static IArgumentOperation FindArgument(ImmutableArray<IArgumentOperation> 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];
}
}
Loading
Loading