Files
RobustToolbox/Robust.Analyzers/ValidateMemberAnalyzer.cs
Tayrtahn d7abbad717 Add validation for DirtyField strings (#5713)
* Add ValidateMemberAttribute, analyzer and test

* Use attribute on DirtyFields methods

* Defer member lookup

* Additional test case

* Add support for collection types

* Poke tests

* Revert "Add support for collection types"

This reverts commit 2b8f5534bd.

* break, not continue

* Cheaper attribute check with AttributeHelper

* Clean up unused helper method

---------

Co-authored-by: PJB3005 <pieterjan.briers+git@gmail.com>
2025-12-17 19:32:34 +01:00

96 lines
3.8 KiB
C#

#nullable enable
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Robust.Roslyn.Shared;
namespace Robust.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ValidateMemberAnalyzer : DiagnosticAnalyzer
{
private const string ValidateMemberType = "Robust.Shared.Analyzers.ValidateMemberAttribute";
private static readonly DiagnosticDescriptor ValidateMemberDescriptor = new(
Diagnostics.IdValidateMember,
"Invalid member name",
"{0} is not a member of {1}",
"Usage",
DiagnosticSeverity.Error,
true,
"Be sure the type and member name are correct.");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [ValidateMemberDescriptor];
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
context.RegisterSyntaxNodeAction(AnalyzeExpression, SyntaxKind.InvocationExpression);
}
private void AnalyzeExpression(SyntaxNodeAnalysisContext context)
{
if (context.Node is not InvocationExpressionSyntax node)
return;
if (context.SemanticModel.GetSymbolInfo(node.Expression).Symbol is not IMethodSymbol methodSymbol)
return;
// We need at least one type argument for context
if (methodSymbol.TypeArguments.Length < 1)
return;
// We'll be checking members of the first type argument
if (methodSymbol.TypeArguments[0] is not INamedTypeSymbol targetType)
return;
// We defer building this set until we need it later, so we don't have to build it for every single method invocation!
ImmutableHashSet<ISymbol>? members = null;
// Check each parameter of the method
foreach (var parameterContext in node.ArgumentList.Arguments)
{
// Get the symbol for this parameter
if (context.SemanticModel.GetOperation(parameterContext) is not IArgumentOperation op || op.Parameter is null)
continue;
var parameterSymbol = op.Parameter.OriginalDefinition;
// Make sure the parameter has the ValidateMember attribute
if (!AttributeHelper.HasAttribute(parameterSymbol, ValidateMemberType, out _))
continue;
// Find the value passed for this parameter.
// We use GetConstantValue to resolve compile-time values - i.e. the result of nameof()
if (context.SemanticModel.GetConstantValue(parameterContext.Expression).Value is not string fieldName)
continue;
// Get a set containing all the members of the target type and its ancestors
members ??= targetType.GetBaseTypesAndThis().SelectMany(n => n.GetMembers()).ToImmutableHashSet(SymbolEqualityComparer.Default);
// Check each member of the target type to see if it matches our passed in value
var found = false;
foreach (var member in members)
{
if (member.Name == fieldName)
{
found = true;
break;
}
}
// If we didn't find it, report the violation
if (!found)
context.ReportDiagnostic(Diagnostic.Create(
ValidateMemberDescriptor,
parameterContext.GetLocation(),
fieldName,
targetType.Name
));
}
}
}