mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
267351b760
* 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
700 lines
28 KiB
C#
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></c> or <c>Executor<TIn, TOut></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<T>.
|
|
/// </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<TResult> 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<T>).</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., "<T, U>") 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}>";
|
|
}
|
|
}
|