Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs
T
Jacob Alber 267351b760 .NET: Expand Workflow Unit Test Coverage (#5390)
* refactor: remove dead code

* refactor: remove ignore YieldsMessageAttribute

- the correct one to use is YieldsOutputAttribute
- fixes a comment that mistakenly refers to `.YieldsMessage()` which does not exist.

* fix: ChatForwardingExecutor does not use correct role for string messages

- make ChatForwardingExecutor use its configured role for string messages rather than always use ChatRole.User
- add ChatForwardingExecutor tests

* fixup: remove unused attribute

* test: Add tests for failure when .AsAgent used on a non-ChatProtocol workflow

* test: Add FunctionExecutor tests

- also fixes Send and YieldOutput type registration for synchronous output-returning delegates

* test: Suppress CodeCoverage for obsolete names

* fix: Re-add Obsolete attributes

- avoid hard-breaking change
- properly notify users that these attributes get ignored
2026-04-21 18:27:50 +00:00

700 lines
28 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
using Microsoft.Agents.AI.Workflows.Generators.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Microsoft.Agents.AI.Workflows.Generators.Analysis;
/// <summary>
/// Provides semantic analysis of executor route candidates.
/// </summary>
/// <remarks>
/// Analysis is split into two phases for efficiency with incremental generators:
/// <list type="number">
/// <item><see cref="AnalyzeHandlerMethod"/> - Called per method, extracts data and performs method-level validation only.</item>
/// <item><see cref="CombineHandlerMethodResults"/> - Groups methods by class and performs class-level validation once.</item>
/// </list>
/// This avoids redundant class validation when multiple handlers exist in the same class.
/// </remarks>
internal static class SemanticAnalyzer
{
// Fully-qualified type names used for symbol comparison
private const string ExecutorTypeName = "Microsoft.Agents.AI.Workflows.Executor";
private const string WorkflowContextTypeName = "Microsoft.Agents.AI.Workflows.IWorkflowContext";
private const string CancellationTokenTypeName = "System.Threading.CancellationToken";
private const string ValueTaskTypeName = "System.Threading.Tasks.ValueTask";
private const string MessageHandlerAttributeName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
private const string SendsMessageAttributeName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute";
private const string YieldsOutputAttributeName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute";
/// <summary>
/// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName.
/// Returns a MethodAnalysisResult containing both method info and class context.
/// </summary>
/// <remarks>
/// This method only extracts raw data and performs method-level validation.
/// Class-level validation is deferred to <see cref="CombineHandlerMethodResults"/> to avoid
/// redundant validation when a class has multiple handler methods.
/// </remarks>
public static MethodAnalysisResult AnalyzeHandlerMethod(
GeneratorAttributeSyntaxContext context,
CancellationToken cancellationToken)
{
// The target should be a method
if (context.TargetSymbol is not IMethodSymbol methodSymbol)
{
return MethodAnalysisResult.Empty;
}
// Get the containing class
INamedTypeSymbol? classSymbol = methodSymbol.ContainingType;
if (classSymbol is null)
{
return MethodAnalysisResult.Empty;
}
// Get the method syntax for location info
MethodDeclarationSyntax? methodSyntax = context.TargetNode as MethodDeclarationSyntax;
// Extract class-level info (raw facts, no validation here)
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
// Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
: classSymbol.ContainingNamespace?.ToDisplayString();
string className = classSymbol.Name;
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
ImmutableEquatableArray<string> classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
ImmutableEquatableArray<string> classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName);
// Get class location for class-level diagnostics
DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken);
// Analyze the handler method (method-level validation only)
// Skip method analysis if class doesn't derive from Executor (class-level diagnostic will be reported later)
var methodDiagnostics = ImmutableArray.CreateBuilder<DiagnosticInfo>();
HandlerInfo? handler = null;
if (derivesFromExecutor)
{
handler = AnalyzeHandler(methodSymbol, methodSyntax, methodDiagnostics);
}
return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, hasManualConfigureProtocol,
classLocation,
handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
}
/// <summary>
/// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult.
/// Performs class-level validation once (instead of per-method) for efficiency.
/// </summary>
public static AnalysisResult CombineHandlerMethodResults(IEnumerable<MethodAnalysisResult> methodResults)
{
List<MethodAnalysisResult> methods = methodResults.ToList();
if (methods.Count == 0)
{
return AnalysisResult.Empty;
}
// All methods should have same class info - take from first
MethodAnalysisResult first = methods[0];
Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
// Collect method-level diagnostics
var allDiagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
foreach (var method in methods)
{
foreach (var diag in method.Diagnostics)
{
allDiagnostics.Add(diag.ToRoslynDiagnostic(null));
}
}
// Class-level validation (done once, not per-method)
if (!first.DerivesFromExecutor)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.NotAnExecutor,
classLocation,
first.ClassName,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (!first.IsPartialClass)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ClassMustBePartial,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (first.HasManualConfigureProtocol)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
// Collect valid handlers
ImmutableArray<HandlerInfo> handlers = methods
.Where(m => m.Handler is not null)
.Select(m => m.Handler!)
.ToImmutableArray();
if (handlers.Length == 0)
{
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
ExecutorInfo executorInfo = new(
first.Namespace,
first.ClassName,
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
first.BaseHasConfigureProtocol,
new ImmutableEquatableArray<HandlerInfo>(handlers),
first.ClassSendTypes,
first.ClassYieldTypes);
if (allDiagnostics.Count > 0)
{
return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
}
return AnalysisResult.Success(executorInfo);
}
/// <summary>
/// Analyzes a class with [SendsMessage] or [YieldsOutput] attribute found by ForAttributeWithMetadataName.
/// Returns ClassProtocolInfo entries for each attribute instance (handles multiple attributes of same type).
/// </summary>
/// <param name="context">The generator attribute syntax context.</param>
/// <param name="attributeKind">Whether this is a Send or Yield attribute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The analysis results for the class protocol attributes.</returns>
public static ImmutableArray<ClassProtocolInfo> AnalyzeClassProtocolAttribute(
GeneratorAttributeSyntaxContext context,
ProtocolAttributeKind attributeKind,
CancellationToken cancellationToken)
{
// The target should be a class
if (context.TargetSymbol is not INamedTypeSymbol classSymbol)
{
return ImmutableArray<ClassProtocolInfo>.Empty;
}
// Extract class-level info (same for all attributes)
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
: classSymbol.ContainingNamespace?.ToDisplayString();
string className = classSymbol.Name;
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken);
// Extract a ClassProtocolInfo for each attribute instance
ImmutableArray<ClassProtocolInfo>.Builder results = ImmutableArray.CreateBuilder<ClassProtocolInfo>();
foreach (AttributeData attr in context.Attributes)
{
if (attr.ConstructorArguments.Length > 0 &&
attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
{
string typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
results.Add(new ClassProtocolInfo(
classKey,
@namespace,
className,
genericParameters,
isNested,
containingTypeChain,
isPartialClass,
derivesFromExecutor,
hasManualConfigureProtocol,
baseHasConfigureProtocol,
classLocation,
typeName,
attributeKind));
}
}
return results.ToImmutable();
}
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol
/// configuration.
/// </summary>
/// <remarks>
/// This is likely to be seen combined with the basic one-method <c>Executor%lt;TIn&gt;</c> or <c>Executor&lt;TIn, TOut&gt;</c>
/// </remarks>
/// <param name="protocolInfos">The protocol info entries for the class.</param>
/// <returns>The combined analysis result.</returns>
public static AnalysisResult CombineOutputOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
{
List<ClassProtocolInfo> protocols = protocolInfos.ToList();
if (protocols.Count == 0)
{
return AnalysisResult.Empty;
}
// All entries should have same class info - take from first
ClassProtocolInfo first = protocols[0];
Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
ImmutableArray<Diagnostic>.Builder allDiagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
// Class-level validation
if (!first.DerivesFromExecutor)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.NotAnExecutor,
classLocation,
first.ClassName,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (!first.IsPartialClass)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ClassMustBePartial,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
// Collect send and yield types
ImmutableArray<string>.Builder sendTypes = ImmutableArray.CreateBuilder<string>();
ImmutableArray<string>.Builder yieldTypes = ImmutableArray.CreateBuilder<string>();
foreach (ClassProtocolInfo protocol in protocols)
{
if (protocol.AttributeKind == ProtocolAttributeKind.Send)
{
sendTypes.Add(protocol.TypeName);
}
else
{
yieldTypes.Add(protocol.TypeName);
}
}
// Sort to ensure consistent ordering for incremental generator caching
sendTypes.Sort(StringComparer.Ordinal);
yieldTypes.Sort(StringComparer.Ordinal);
// Create ExecutorInfo with no handlers but with protocol types
ExecutorInfo executorInfo = new(
first.Namespace,
first.ClassName,
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
first.BaseHasConfigureProtocol,
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
if (allDiagnostics.Count > 0)
{
return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
}
return AnalysisResult.Success(executorInfo);
}
/// <summary>
/// Gets the source location of the class identifier for diagnostic reporting.
/// </summary>
private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
{
foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
{
SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken);
if (syntax is ClassDeclarationSyntax classDecl)
{
return DiagnosticLocationInfo.FromLocation(classDecl.Identifier.GetLocation());
}
}
return null;
}
/// <summary>
/// Returns a unique identifier for the class used to group methods by their containing type.
/// </summary>
private static string GetClassKey(INamedTypeSymbol classSymbol)
{
return classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
/// <summary>
/// Checks if any declaration of the class has the 'partial' modifier.
/// </summary>
private static bool IsPartialClass(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
{
foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
{
SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken);
if (syntax is ClassDeclarationSyntax classDecl &&
classDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
{
return true;
}
}
return false;
}
/// <summary>
/// Walks the inheritance chain to check if the class derives from Executor or Executor&lt;T&gt;.
/// </summary>
private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? current = classSymbol.BaseType;
while (current != null)
{
string fullName = current.OriginalDefinition.ToDisplayString();
if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", StringComparison.Ordinal))
{
return true;
}
current = current.BaseType;
}
return false;
}
/// <summary>
/// Checks if this class directly defines ConfigureProtocol (not inherited).
/// If so, we skip generation to avoid conflicting with user's manual implementation.
/// </summary>
private static bool HasConfigureProtocolDefined(INamedTypeSymbol classSymbol)
{
foreach (var member in classSymbol.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract &&
SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol))
{
return true;
}
}
return false;
}
/// <summary>
/// Checks if any base class (between this class and Executor) defines ConfigureProtocol.
/// If so, generated code should call base.ConfigureProtocol() to preserve inherited handlers.
/// </summary>
private static bool BaseHasConfigureProtocol(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? baseType = classSymbol.BaseType;
while (baseType != null)
{
string fullName = baseType.OriginalDefinition.ToDisplayString();
// Stop at Executor - its ConfigureProtocol is abstract/empty
if (fullName == ExecutorTypeName)
{
return false;
}
foreach (var member in baseType.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract)
{
return true;
}
}
baseType = baseType.BaseType;
}
return false;
}
/// <summary>
/// Validates a handler method's signature and extracts metadata.
/// </summary>
/// <remarks>
/// Valid signatures:
/// <list type="bullet">
/// <item><c>void Handle(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>ValueTask HandleAsync(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>ValueTask&lt;TResult&gt; HandleAsync(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>TResult Handle(TMessage, IWorkflowContext, [CancellationToken])</c> (sync with result)</item>
/// </list>
/// </remarks>
private static HandlerInfo? AnalyzeHandler(
IMethodSymbol methodSymbol,
MethodDeclarationSyntax? methodSyntax,
ImmutableArray<DiagnosticInfo>.Builder diagnostics)
{
Location location = methodSyntax?.Identifier.GetLocation() ?? Location.None;
// Check if static
if (methodSymbol.IsStatic)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF007", location, methodSymbol.Name));
return null;
}
// Check parameter count
if (methodSymbol.Parameters.Length < 2)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF005", location, methodSymbol.Name));
return null;
}
// Check second parameter is IWorkflowContext
IParameterSymbol secondParam = methodSymbol.Parameters[1];
if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF001", location, methodSymbol.Name));
return null;
}
// Check for optional CancellationToken as third parameter
bool hasCancellationToken = methodSymbol.Parameters.Length >= 3 &&
methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName;
// Analyze return type
ITypeSymbol returnType = methodSymbol.ReturnType;
HandlerSignatureKind? signatureKind = GetSignatureKind(returnType);
if (signatureKind == null)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name));
return null;
}
// Get input type
ITypeSymbol inputType = methodSymbol.Parameters[0].Type;
string inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Get output type
string? outputTypeName = null;
if (signatureKind == HandlerSignatureKind.ResultSync)
{
outputTypeName = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
else if (signatureKind == HandlerSignatureKind.ResultAsync && returnType is INamedTypeSymbol namedReturn)
{
if (namedReturn.TypeArguments.Length == 1)
{
outputTypeName = namedReturn.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
}
// Get Yield and Send types from attribute
(ImmutableEquatableArray<string> yieldTypes, ImmutableEquatableArray<string> sendTypes) = GetAttributeTypeArrays(methodSymbol);
return new HandlerInfo(
methodSymbol.Name,
inputTypeName,
outputTypeName,
signatureKind.Value,
hasCancellationToken,
yieldTypes,
sendTypes);
}
/// <summary>
/// Determines the handler signature kind from the return type.
/// </summary>
/// <returns>The signature kind, or null if the return type is not supported (e.g., Task, Task&lt;T&gt;).</returns>
private static HandlerSignatureKind? GetSignatureKind(ITypeSymbol returnType)
{
string returnTypeName = returnType.ToDisplayString();
if (returnType.SpecialType == SpecialType.System_Void)
{
return HandlerSignatureKind.VoidSync;
}
if (returnTypeName == ValueTaskTypeName)
{
return HandlerSignatureKind.VoidAsync;
}
if (returnType is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.ValueTask<TResult>")
{
return HandlerSignatureKind.ResultAsync;
}
// Any non-void, non-Task type is treated as a synchronous result
if (returnType.SpecialType != SpecialType.System_Void &&
!returnTypeName.StartsWith("System.Threading.Tasks.Task", StringComparison.Ordinal) &&
!returnTypeName.StartsWith("System.Threading.Tasks.ValueTask", StringComparison.Ordinal))
{
return HandlerSignatureKind.ResultSync;
}
// Task/Task<T> not supported - must use ValueTask
return null;
}
/// <summary>
/// Extracts Yield and Send type arrays from the [MessageHandler] attribute's named arguments.
/// </summary>
/// <example>
/// [MessageHandler(Yield = new[] { typeof(OutputA), typeof(OutputB) }, Send = new[] { typeof(Request) })]
/// </example>
private static (ImmutableEquatableArray<string> YieldTypes, ImmutableEquatableArray<string> SendTypes) GetAttributeTypeArrays(
IMethodSymbol methodSymbol)
{
var yieldTypes = ImmutableArray<string>.Empty;
var sendTypes = ImmutableArray<string>.Empty;
foreach (var attr in methodSymbol.GetAttributes())
{
if (attr.AttributeClass?.ToDisplayString() != MessageHandlerAttributeName)
{
continue;
}
foreach (var namedArg in attr.NamedArguments)
{
if (namedArg.Key.Equals("Yield", StringComparison.Ordinal) && !namedArg.Value.IsNull)
{
yieldTypes = ExtractTypeArray(namedArg.Value);
}
else if (namedArg.Key.Equals("Send", StringComparison.Ordinal) && !namedArg.Value.IsNull)
{
sendTypes = ExtractTypeArray(namedArg.Value);
}
}
}
return (new ImmutableEquatableArray<string>(yieldTypes), new ImmutableEquatableArray<string>(sendTypes));
}
/// <summary>
/// Converts a TypedConstant array (from attribute argument) to fully-qualified type name strings.
/// </summary>
/// <remarks>
/// Results are sorted to ensure consistent ordering for incremental generator caching.
/// </remarks>
private static ImmutableArray<string> ExtractTypeArray(TypedConstant typedConstant)
{
if (typedConstant.Kind != TypedConstantKind.Array)
{
return ImmutableArray<string>.Empty;
}
ImmutableArray<string>.Builder builder = ImmutableArray.CreateBuilder<string>();
foreach (TypedConstant value in typedConstant.Values)
{
if (value.Value is INamedTypeSymbol typeSymbol)
{
builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
}
// Sort to ensure consistent ordering for incremental generator caching
builder.Sort(StringComparer.Ordinal);
return builder.ToImmutable();
}
/// <summary>
/// Collects types from [SendsMessage] or [YieldsOutput] attributes applied to the class.
/// </summary>
/// <remarks>
/// Results are sorted to ensure consistent ordering for incremental generator caching,
/// since GetAttributes() order is not guaranteed across partial class declarations.
/// </remarks>
/// <example>
/// [SendsMessage(typeof(Request))]
/// [YieldsOutput(typeof(Response))]
/// public partial class MyExecutor : Executor { }
/// </example>
private static ImmutableEquatableArray<string> GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName)
{
ImmutableArray<string>.Builder builder = ImmutableArray.CreateBuilder<string>();
foreach (AttributeData attr in classSymbol.GetAttributes())
{
if (attr.AttributeClass?.ToDisplayString() == attributeName &&
attr.ConstructorArguments.Length > 0 &&
attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
{
builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
}
// Sort to ensure consistent ordering for incremental generator caching
builder.Sort(StringComparer.Ordinal);
return new ImmutableEquatableArray<string>(builder.ToImmutable());
}
/// <summary>
/// Builds the chain of containing types for nested classes, outermost first.
/// </summary>
/// <example>
/// For class Outer.Middle.Inner.MyExecutor, returns "Outer.Middle.Inner"
/// </example>
private static string GetContainingTypeChain(INamedTypeSymbol classSymbol)
{
List<string> chain = new();
INamedTypeSymbol? current = classSymbol.ContainingType;
while (current != null)
{
chain.Insert(0, current.Name);
current = current.ContainingType;
}
return string.Join(".", chain);
}
/// <summary>
/// Returns the generic type parameter clause (e.g., "&lt;T, U&gt;") for generic classes, or null for non-generic.
/// </summary>
private static string? GetGenericParameters(INamedTypeSymbol classSymbol)
{
if (!classSymbol.IsGenericType)
{
return null;
}
string parameters = string.Join(", ", classSymbol.TypeParameters.Select(p => p.Name));
return $"<{parameters}>";
}
}