Skip to content
Open
8 changes: 8 additions & 0 deletions src/Components/Analyzers/src/ComponentsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,12 @@ public static class RenderTreeBuilder
public const string FullTypeName = "Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder";
public const string MetadataName = FullTypeName;
}

public static class RazorComponentResultOfT
{
public const string FullTypeName = "Microsoft.AspNetCore.Http.HttpResults.RazorComponentResult<TComponent>";

// The generic type is defined in Microsoft.AspNetCore.Components.Endpoints and has arity 1.
public const string MetadataName = "Microsoft.AspNetCore.Http.HttpResults.RazorComponentResult`1";
}
}
9 changes: 9 additions & 0 deletions src/Components/Analyzers/src/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,13 @@ internal static class DiagnosticDescriptors
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: CreateLocalizableResourceString(nameof(Resources.JSInvokableMethodShouldBePublic_Description)));

public static readonly DiagnosticDescriptor RazorComponentResultParameterDoesNotExist = new(
"BL0016",
CreateLocalizableResourceString(nameof(Resources.RazorComponentResultParameterDoesNotExist_Title)),
CreateLocalizableResourceString(nameof(Resources.RazorComponentResultParameterDoesNotExist_Format)),
Usage,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: CreateLocalizableResourceString(nameof(Resources.RazorComponentResultParameterDoesNotExist_Description)));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

#nullable enable

namespace Microsoft.AspNetCore.Components.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class RazorComponentResultParameterAnalyzer : DiagnosticAnalyzer
{
public RazorComponentResultParameterAnalyzer()
{
SupportedDiagnostics = ImmutableArray.Create(
DiagnosticDescriptors.RazorComponentResultParameterDoesNotExist);
}

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
context.RegisterCompilationStartAction(context =>
{
if (!ComponentSymbols.TryCreate(context.Compilation, out var symbols))
{
// Types we need are not defined.
return;
}

var razorComponentResultOfT = context.Compilation.GetTypeByMetadataName(ComponentsApi.RazorComponentResultOfT.MetadataName);
if (razorComponentResultOfT is null)
{
// RazorComponentResult<TComponent> is not referenced by this compilation.
return;
}

context.RegisterOperationAction(context =>
{
var objectCreation = (IObjectCreationOperation)context.Operation;

// We only care about constructing RazorComponentResult<TComponent> with a single loosely-typed
// parameters argument, which is the ergonomic (and error-prone) overload from the issue.
if (objectCreation.Type is not INamedTypeSymbol createdType ||
!SymbolEqualityComparer.Default.Equals(createdType.OriginalDefinition, razorComponentResultOfT))
{
return;
}

if (createdType.TypeArguments.Length != 1 ||
createdType.TypeArguments[0] is not INamedTypeSymbol componentType)
{
// The component type is an open generic type parameter; we cannot resolve its parameters.
return;
}

if (objectCreation.Arguments.Length != 1)
{
return;
}

if (UnwrapConversions(objectCreation.Arguments[0].Value) is not IAnonymousObjectCreationOperation anonymousObject)
{
// Only anonymous objects (e.g. new { Foo = 1 }) expose statically known parameter names.
// Dictionaries and named types are matched by name at runtime and are intentionally ignored.
return;
}

if (!TryGetComponentParameterNames(symbols, componentType, out var parameterNames))
{
// The component captures unmatched values, so any parameter name is accepted at runtime.
return;
}

foreach (var initializer in anonymousObject.Initializers)
{
if (initializer is not ISimpleAssignmentOperation { Target: IPropertyReferenceOperation propertyReference })
{
continue;
}

var parameterName = propertyReference.Property.Name;
if (parameterNames.Contains(parameterName))
{
continue;
}

context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.RazorComponentResultParameterDoesNotExist,
GetParameterNameLocation(initializer.Syntax),
componentType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
parameterName));
Comment thread
dkamburov marked this conversation as resolved.
}
}, OperationKind.ObjectCreation);
});
}

private static IOperation UnwrapConversions(IOperation operation)
{
while (operation is IConversionOperation conversion)
{
operation = conversion.Operand;
}

return operation;
}

private static bool TryGetComponentParameterNames(
ComponentSymbols symbols,
INamedTypeSymbol componentType,
out HashSet<string> parameterNames)
{
parameterNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

for (INamedTypeSymbol? type = componentType; type is not null; type = type.BaseType)
{
foreach (var member in type.GetMembers())
{
if (member is not IPropertySymbol property)
{
continue;
}

if (ComponentFacts.IsParameterWithCaptureUnmatchedValues(symbols, property))
{
// The component accepts arbitrary parameter names via CaptureUnmatchedValues,
// so no name can be considered invalid.
return false;
}

if (ComponentFacts.IsParameter(symbols, property))
{
parameterNames.Add(property.Name);
}
Comment thread
dkamburov marked this conversation as resolved.
}
}

return true;
}

private static Location GetParameterNameLocation(SyntaxNode syntax)
{
// Point the diagnostic at the member name (e.g. `Foo` in `Foo = 1`) rather than the whole declarator.
return syntax switch
{
AnonymousObjectMemberDeclaratorSyntax { NameEquals: { } nameEquals } => nameEquals.Name.GetLocation(),
AnonymousObjectMemberDeclaratorSyntax { Expression: IdentifierNameSyntax identifier } => identifier.GetLocation(),
_ => syntax.GetLocation(),
};
}
}
12 changes: 11 additions & 1 deletion src/Components/Analyzers/src/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,14 @@
<data name="JSInvokableMethodShouldBePublic_FixTitle" xml:space="preserve">
<value>Make [JSInvokable] method public.</value>
</data>
</root>
</root>
<data name="RazorComponentResultParameterDoesNotExist_Description" xml:space="preserve">
<value>Parameters passed to RazorComponentResult&lt;TComponent&gt; are matched by name to [Parameter] properties on the component when it renders. Supplying a name that does not correspond to a component parameter throws an InvalidOperationException at runtime.</value>
</data>
<data name="RazorComponentResultParameterDoesNotExist_Format" xml:space="preserve">
<value>Component '{0}' does not have a [Parameter] property matching the name '{1}'.</value>
</data>
Comment thread
dkamburov marked this conversation as resolved.
<data name="RazorComponentResultParameterDoesNotExist_Title" xml:space="preserve">
<value>RazorComponentResult parameter does not match a component parameter</value>
</data>
</root>
Loading
Loading