From 68b70da0c24d28d30b90d9cad966ff37b6eb705e Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Tue, 31 Mar 2026 07:44:09 -0500 Subject: [PATCH 01/14] Introduce TriggerDefinition with TriggerShape and ParameterRole for transport-agnostic generator seams - Decouple source generator from Azure Service Bus via TriggerDefinition - Add ParameterRole classification and TriggerShape for method signature validation - Consolidate function method diagnostics into single NSBFUNC006 with collect-all validation - Add InvalidFunctionMethodCodeFixProvider (scaffolds missing params and Configure method) - Wire CodeFixes project into integration test projects --- .../IntegrationTest.Billing.csproj | 3 + .../IntegrationTest.Sales.csproj | 3 + .../IntegrationTest.Shipping.csproj | 3 + src/IntegrationTest/IntegrationTest.csproj | 3 + .../DiagnosticIds.cs | 9 + ...nctionEndpointGenerator.AzureServiceBus.cs | 23 ++ .../FunctionEndpointGenerator.Emitter.cs | 4 +- .../FunctionEndpointGenerator.Parser.cs | 282 +++++++++++++--- ...tionEndpointGenerator.TriggerDefinition.cs | 50 +++ .../FunctionEndpointGenerator.cs | 5 +- ...NServiceBus.AzureFunctions.Analyzer.csproj | 4 + .../Class1.cs | 7 - .../MissingConfigureMethodCodeFixProvider.cs | 131 ++++++++ ...ServiceBus.AzureFunctions.CodeFixes.csproj | 1 + ...apeAllowsAdditionalParameters.approved.txt | 50 +++ ...thExtraUnrecognizedParameters.approved.txt | 50 +++ ...EndpointWithoutMessageActions.approved.txt | 49 +++ ...tWhenTriggerEntityNameMissing.approved.txt | 0 ...ctionEndpointGeneratorLenientShapeTests.cs | 45 +++ .../FunctionEndpointGeneratorTests.cs | 303 +++++++++++++++++- .../LenientNoMessageActionsGenerator.cs | 32 ++ ...eBus.AzureFunctions.Analyzers.Tests.csproj | 6 +- .../NoMessageActionsGenerator.cs | 36 +++ src/Tests.Analyzers/TestSources.cs | 27 ++ 24 files changed, 1067 insertions(+), 59 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs create mode 100644 src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs delete mode 100644 src/NServiceBus.AzureFunctions.CodeFixes/Class1.cs create mode 100644 src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs create mode 100644 src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt create mode 100644 src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithExtraUnrecognizedParameters.approved.txt create mode 100644 src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt create mode 100644 src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.ProducesNoEndpointWhenTriggerEntityNameMissing.approved.txt create mode 100644 src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs create mode 100644 src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs create mode 100644 src/Tests.Analyzers/NoMessageActionsGenerator.cs diff --git a/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj b/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj index 9a605db..24067ff 100644 --- a/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj +++ b/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj @@ -11,6 +11,9 @@ + diff --git a/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj b/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj index 9a605db..24067ff 100644 --- a/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj +++ b/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj @@ -11,6 +11,9 @@ + diff --git a/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj b/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj index 9a605db..24067ff 100644 --- a/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj +++ b/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj @@ -11,6 +11,9 @@ + diff --git a/src/IntegrationTest/IntegrationTest.csproj b/src/IntegrationTest/IntegrationTest.csproj index 7935fcd..bd45588 100644 --- a/src/IntegrationTest/IntegrationTest.csproj +++ b/src/IntegrationTest/IntegrationTest.csproj @@ -13,6 +13,9 @@ + diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 814e42e..ae87666 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -10,6 +10,7 @@ public static class DiagnosticIds public const string MissingAddNServiceBusFunctionsCall = "NSBFUNC004"; public const string MultipleConfigureMethods = "NSBFUNC005"; public const string AutoCompleteEnabled = "NSBFUNC006"; + public const string InvalidFunctionMethod = "NSBFUNC007"; internal static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( id: ClassMustBePartial, @@ -51,6 +52,14 @@ public static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + internal static readonly DiagnosticDescriptor InvalidFunctionMethodDescriptor = new( + id: InvalidFunctionMethod, + title: "Invalid NServiceBus function method", + messageFormat: "Method '{0}' is not a valid NServiceBus function: {1}", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + internal static readonly DiagnosticDescriptor MissingAddNServiceBusFunctionsCallDescriptor = new( id: MissingAddNServiceBusFunctionsCall, title: "AddNServiceBusFunctions() is not called", diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs new file mode 100644 index 0000000..3c26848 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -0,0 +1,23 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using NServiceBus.Core.Analyzer; + +public sealed partial class FunctionEndpointGenerator +{ + static readonly ParameterRole MessageActions = new("MessageActions"); + + static readonly TriggerDefinition AzureServiceBusTrigger = new( + TriggerAttributeMetadataName: "Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute", + AdditionalParameterTypes: new AdditionalParameterType[] + { + new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) + }.ToImmutableEquatableArray(), + ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", + ConnectionPropertyName: "Connection", + ProcessMethodName: "Process", + Shape: TriggerShape.Required( + ParameterRole.TriggerMessage, + MessageActions, + ParameterRole.FunctionContext, + ParameterRole.CancellationToken)); +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs index 8f79681..2fdbb24 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs @@ -56,14 +56,14 @@ static void EmitMethodBodies(SourceProductionContext spc, ImmutableArray(\"{func.FunctionName}\");"); + writer.WriteLine($" .GetKeyedService<{func.ProcessorTypeFullyQualified}>(\"{func.FunctionName}\");"); writer.WriteLine("if (processor is null)"); writer.WriteLine("{"); writer.Indentation++; writer.WriteLine($"throw new global::System.InvalidOperationException(\"{func.FunctionName} has not been registered.\");"); writer.Indentation--; writer.WriteLine("}"); - writer.WriteLine($"return processor.Process({func.MessageParamName}, {func.MessageActionsParamName}, {func.FunctionContextParamName}, {func.CancellationTokenParamName});"); + writer.WriteLine($"return {func.ProcessCallExpression};"); writer.Indentation--; writer.WriteLine("}"); } diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index a1caf6f..cd8a141 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -1,5 +1,6 @@ namespace NServiceBus.AzureFunctions.Analyzer; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Threading; @@ -12,7 +13,7 @@ public sealed partial class FunctionEndpointGenerator { static class Parser { - internal static FunctionSpecs Extract(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken = default) + internal static FunctionSpecs Extract(GeneratorAttributeSyntaxContext context, TriggerDefinition triggerDefinition, CancellationToken cancellationToken = default) { if (context.Attributes.Length == 0) { @@ -21,20 +22,20 @@ internal static FunctionSpecs Extract(GeneratorAttributeSyntaxContext context, C cancellationToken.ThrowIfCancellationRequested(); - if (!FunctionEndpointGeneratorKnownTypes.TryGet(context.SemanticModel.Compilation, out var knownTypes)) + if (!FunctionEndpointGeneratorKnownTypes.TryGet(context.SemanticModel.Compilation, triggerDefinition, out var knownTypes)) { return FunctionSpecs.Empty; } return context.TargetSymbol switch { - INamedTypeSymbol classSymbol => ExtractFromClass(classSymbol, knownTypes, cancellationToken), - IMethodSymbol methodSymbol => ExtractFromMethod(methodSymbol, knownTypes, cancellationToken), + INamedTypeSymbol classSymbol => ExtractFromClass(classSymbol, knownTypes, triggerDefinition, cancellationToken), + IMethodSymbol methodSymbol => ExtractFromMethod(methodSymbol, knownTypes, triggerDefinition, cancellationToken), _ => FunctionSpecs.Empty }; } - static FunctionSpecs ExtractFromClass(INamedTypeSymbol classSymbol, FunctionEndpointGeneratorKnownTypes knownTypes, CancellationToken cancellationToken) + static FunctionSpecs ExtractFromClass(INamedTypeSymbol classSymbol, FunctionEndpointGeneratorKnownTypes knownTypes, TriggerDefinition triggerDefinition, CancellationToken cancellationToken) { var functions = new List(); var diagnostics = new List(); @@ -54,7 +55,7 @@ static FunctionSpecs ExtractFromClass(INamedTypeSymbol classSymbol, FunctionEndp cancellationToken.ThrowIfCancellationRequested(); if (member is IMethodSymbol method) { - var spec = ExtractFunctionSpec(method, knownTypes, diagnostics); + var spec = ExtractFunctionSpec(method, knownTypes, triggerDefinition, diagnostics); if (spec is not null) { functions.Add(spec); @@ -65,7 +66,7 @@ static FunctionSpecs ExtractFromClass(INamedTypeSymbol classSymbol, FunctionEndp return new FunctionSpecs(functions.ToImmutableEquatableArray(), diagnostics.ToImmutableEquatableArray()); } - static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpointGeneratorKnownTypes knownTypes, CancellationToken cancellationToken) + static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpointGeneratorKnownTypes knownTypes, TriggerDefinition triggerDefinition, CancellationToken cancellationToken) { var diagnostics = new List(); @@ -79,12 +80,12 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo diagnostics.Add(CreateDiagnostic(DiagnosticIds.ClassMustBePartialDescriptor, methodSymbol.ContainingType, methodSymbol.ContainingType.Name)); } - var spec = ExtractFunctionSpec(methodSymbol, knownTypes, diagnostics); + var spec = ExtractFunctionSpec(methodSymbol, knownTypes, triggerDefinition, diagnostics); var functions = spec is null ? ImmutableEquatableArray.Empty : ((FunctionSpec[])[spec]).ToImmutableEquatableArray(); return new FunctionSpecs(functions, diagnostics.ToImmutableEquatableArray()); } - static FunctionSpec? ExtractFunctionSpec(IMethodSymbol method, FunctionEndpointGeneratorKnownTypes knownTypes, List diagnostics) + static FunctionSpec? ExtractFunctionSpec(IMethodSymbol method, FunctionEndpointGeneratorKnownTypes knownTypes, TriggerDefinition triggerDefinition, List diagnostics) { if (!TryGetFunctionAttribute(method, knownTypes, out var functionAttr) || functionAttr.ConstructorArguments.Length == 0) { @@ -101,7 +102,9 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo string? messageParamName = null; string? functionContextParamName = null; string? cancellationTokenParamName = null; - string? messageActionsParamName = null; + var additionalParamNames = new Dictionary(); + var parameterRoles = new List(); + var triggerParameterCount = 0; var paramList = new StringBuilder(); @@ -116,10 +119,19 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo var paramTypeFqn = param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); paramList.Append(paramTypeFqn).Append(' ').Append(param.Name); + var hasTriggerAttribute = false; foreach (var pAttr in param.GetAttributes()) { - if (SymbolEqualityComparer.Default.Equals(pAttr.AttributeClass, knownTypes.ServiceBusTriggerAttribute)) + if (SymbolEqualityComparer.Default.Equals(pAttr.AttributeClass, knownTypes.TriggerAttribute)) { + hasTriggerAttribute = true; + triggerParameterCount++; + + if (triggerParameterCount == 1) + { + messageParamName = param.Name; + } + if (pAttr.ConstructorArguments.Length > 0) { queueName = pAttr.ConstructorArguments[0].Value as string; @@ -128,7 +140,7 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo var autoCompleteEnabled = true; foreach (var namedArg in pAttr.NamedArguments) { - if (namedArg.Key == "Connection") + if (namedArg.Key == triggerDefinition.ConnectionPropertyName) { connectionName = namedArg.Value.Value as string; } @@ -144,36 +156,105 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo { diagnostics.Add(CreateDiagnostic(DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, method, method.Name)); } - - messageParamName = param.Name; } } - if (SymbolEqualityComparer.Default.Equals(param.Type, knownTypes.MessageActions)) + var role = ClassifyParameterRole(param, hasTriggerAttribute, knownTypes); + parameterRoles.Add(role); + + if (role is not null && knownTypes.AdditionalParameterSymbols.ContainsKey(role.Value) && !additionalParamNames.ContainsKey(role.Value)) { - messageActionsParamName = param.Name; + additionalParamNames[role.Value] = param.Name; } - if (SymbolEqualityComparer.Default.Equals(param.Type, knownTypes.FunctionContext)) + if (role == ParameterRole.FunctionContext && functionContextParamName is null) { functionContextParamName = param.Name; } - if (SymbolEqualityComparer.Default.Equals(param.Type, knownTypes.CancellationToken)) + if (role == ParameterRole.CancellationToken && cancellationTokenParamName is null) { cancellationTokenParamName = param.Name; } } - if (queueName is null || functionContextParamName is null || messageParamName is null || messageActionsParamName is null) + var problems = new List(); + + if (triggerParameterCount == 0) + { + problems.Add("missing a parameter with a trigger attribute"); + } + else if (triggerParameterCount > 1) + { + problems.Add("must declare exactly one trigger parameter"); + } + + if (messageParamName is not null && queueName is null) + { + problems.Add("trigger attribute does not specify a queue or entity name"); + } + + if (functionContextParamName is null) + { + problems.Add("missing FunctionContext parameter"); + } + + foreach (var kvp in knownTypes.AdditionalParameterSymbols) + { + if (!additionalParamNames.ContainsKey(kvp.Key)) + { + problems.Add($"missing {kvp.Key.Name} parameter"); + } + } + + if (cancellationTokenParamName is null) + { + problems.Add("missing CancellationToken parameter"); + } + + if (!MatchesShape(parameterRoles, triggerDefinition.Shape)) + { + problems.Add($"parameters must match required shape {FormatShape(triggerDefinition.Shape.OrderedParameters)}"); + } + + var containingType = method.ContainingType; + var configureMethodName = $"Configure{functionName}"; + var configureMethod = GetConfigureMethodSpec(containingType, functionName, knownTypes, diagnostics); + if (configureMethod is null) + { + problems.Add($"missing '{configureMethodName}' configuration method"); + } + + if (problems.Count > 0) { + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("ConfigureMethodName", configureMethodName); + if (functionContextParamName is null) + { + properties.Add("MissingFunctionContext", "true"); + } + if (cancellationTokenParamName is null) + { + properties.Add("MissingCancellationToken", "true"); + } + foreach (var kvp in knownTypes.AdditionalParameterSymbols) + { + if (!additionalParamNames.ContainsKey(kvp.Key)) + { + properties.Add($"Missing{kvp.Key.Name}", kvp.Value.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); + } + } + if (configureMethod is null) + { + properties.Add("MissingConfigureMethod", "true"); + } + var location = method.Locations.Length > 0 ? method.Locations[0] : null; + diagnostics.Add(Diagnostic.Create(DiagnosticIds.InvalidFunctionMethodDescriptor, location, properties.ToImmutable(), method.Name, string.Join(", ", problems))); return null; } connectionName ??= ""; - cancellationTokenParamName ??= "cancellationToken"; - var containingType = method.ContainingType; var ns = containingType.ContainingNamespace.ToDisplayString(); var className = containingType.Name; var returnType = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -190,17 +271,119 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo _ => "public", }; - var configureMethod = GetConfigureMethodSpec(containingType, functionName, knownTypes, diagnostics); - if (configureMethod is null) + var processArgParts = new List(); + foreach (var role in triggerDefinition.Shape.OrderedParameters) { - return null; + if (role == ParameterRole.TriggerMessage) + { + processArgParts.Add(messageParamName!); + } + else if (role == ParameterRole.FunctionContext) + { + processArgParts.Add(functionContextParamName!); + } + else if (role == ParameterRole.CancellationToken) + { + processArgParts.Add(cancellationTokenParamName!); + } + else if (additionalParamNames.TryGetValue(role, out var name)) + { + processArgParts.Add(name); + } } + var processArgs = string.Join(", ", processArgParts); + var processCallExpression = $"processor.{triggerDefinition.ProcessMethodName}({processArgs})"; return new FunctionSpec( ns, className, accessibility, method.Name, returnType, - paramList.ToString(), messageParamName, messageActionsParamName, functionContextParamName, - cancellationTokenParamName, functionName, queueName, connectionName, - configureMethod.Value); + paramList.ToString(), functionContextParamName!, + functionName, queueName!, connectionName, + triggerDefinition.ProcessorTypeFullyQualified, processCallExpression, configureMethod!.Value); + } + + static ParameterRole? ClassifyParameterRole(IParameterSymbol parameter, bool hasTriggerAttribute, FunctionEndpointGeneratorKnownTypes knownTypes) + { + if (hasTriggerAttribute) + { + return ParameterRole.TriggerMessage; + } + + foreach (var kvp in knownTypes.AdditionalParameterSymbols) + { + if (SymbolEqualityComparer.Default.Equals(parameter.Type, kvp.Value)) + { + return kvp.Key; + } + } + + if (SymbolEqualityComparer.Default.Equals(parameter.Type, knownTypes.FunctionContext)) + { + return ParameterRole.FunctionContext; + } + + if (SymbolEqualityComparer.Default.Equals(parameter.Type, knownTypes.CancellationToken)) + { + return ParameterRole.CancellationToken; + } + + return null; + } + + static bool MatchesShape(List actualParameterRoles, TriggerShape shape) + { + if (!shape.AllowAdditionalParameters) + { + if (actualParameterRoles.Count != shape.OrderedParameters.Count) + { + return false; + } + + for (var i = 0; i < actualParameterRoles.Count; i++) + { + if (actualParameterRoles[i] is not { } role || role != shape.OrderedParameters[i]) + { + return false; + } + } + + return true; + } + + var roleIndex = 0; + for (var i = 0; i < actualParameterRoles.Count; i++) + { + if (actualParameterRoles[i] is not { } role) + { + continue; + } + + if (roleIndex >= shape.OrderedParameters.Count || role != shape.OrderedParameters[roleIndex]) + { + return false; + } + + roleIndex++; + } + + return roleIndex == shape.OrderedParameters.Count; + } + + static string FormatShape(ImmutableEquatableArray orderedParameters) + { + var builder = new StringBuilder("["); + + for (var i = 0; i < orderedParameters.Count; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(orderedParameters[i]); + } + + builder.Append(']'); + return builder.ToString(); } static ConfigureMethodSpec? GetConfigureMethodSpec(INamedTypeSymbol functionClassType, string endpointName, FunctionEndpointGeneratorKnownTypes knownTypes, List diagnostics) @@ -321,13 +504,12 @@ internal sealed record FunctionSpec( string MethodName, string ReturnType, string ParameterList, - string MessageParamName, - string MessageActionsParamName, string FunctionContextParamName, - string CancellationTokenParamName, string FunctionName, string QueueName, string ConnectionName, + string ProcessorTypeFullyQualified, + string ProcessCallExpression, ConfigureMethodSpec ConfigureMethod); internal readonly record struct FunctionSpecs(ImmutableEquatableArray Functions, ImmutableEquatableArray Diagnostics) @@ -337,64 +519,76 @@ internal readonly record struct FunctionSpecs(ImmutableEquatableArray additionalParameterSymbols) { public INamedTypeSymbol FunctionAttribute { get; } = functionAttribute; - public INamedTypeSymbol ServiceBusTriggerAttribute { get; } = serviceBusTriggerAttribute; + public INamedTypeSymbol TriggerAttribute { get; } = triggerAttribute; public INamedTypeSymbol FunctionContext { get; } = functionContext; public INamedTypeSymbol CancellationToken { get; } = cancellationToken; public INamedTypeSymbol EndpointConfiguration { get; } = endpointConfiguration; public INamedTypeSymbol IHandleMessages { get; } = iHandleMessages; public INamedTypeSymbol IConfiguration { get; } = iConfiguration; public INamedTypeSymbol IHostEnvironment { get; } = iHostEnvironment; - public INamedTypeSymbol MessageActions { get; } = messageActions; + public ImmutableDictionary AdditionalParameterSymbols { get; } = additionalParameterSymbols; - public static bool TryGet(Compilation compilation, out FunctionEndpointGeneratorKnownTypes knownTypes) + public static bool TryGet(Compilation compilation, TriggerDefinition triggerDefinition, out FunctionEndpointGeneratorKnownTypes knownTypes) { var functionAttribute = compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.FunctionAttribute"); - var serviceBusTriggerAttribute = - compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute"); + var triggerAttribute = + compilation.GetTypeByMetadataName(triggerDefinition.TriggerAttributeMetadataName); var functionContext = compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.FunctionContext"); var cancellationToken = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); var endpointConfiguration = compilation.GetTypeByMetadataName("NServiceBus.EndpointConfiguration"); var iHandleMessages = compilation.GetTypeByMetadataName("NServiceBus.IHandleMessages`1"); var iconfiguration = compilation.GetTypeByMetadataName("Microsoft.Extensions.Configuration.IConfiguration"); var iHostEnvironment = compilation.GetTypeByMetadataName("Microsoft.Extensions.Hosting.IHostEnvironment"); - var messageActions = compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions"); + if (functionAttribute is null - || serviceBusTriggerAttribute is null + || triggerAttribute is null || functionContext is null || cancellationToken is null || endpointConfiguration is null || iHandleMessages is null || iconfiguration is null - || iHostEnvironment is null - || messageActions is null) + || iHostEnvironment is null) { knownTypes = default; return false; } + var additionalBuilder = ImmutableDictionary.CreateBuilder(); + foreach (var apt in triggerDefinition.AdditionalParameterTypes) + { + var symbol = compilation.GetTypeByMetadataName(apt.MetadataName); + if (symbol is null) + { + knownTypes = default; + return false; + } + additionalBuilder.Add(apt.Role, symbol); + } + knownTypes = new FunctionEndpointGeneratorKnownTypes( functionAttribute, - serviceBusTriggerAttribute, + triggerAttribute, functionContext, cancellationToken, endpointConfiguration, iHandleMessages, iconfiguration, iHostEnvironment, - messageActions); + additionalBuilder.ToImmutable()); return true; } } -} \ No newline at end of file +} + diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs new file mode 100644 index 0000000..cb6befc --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -0,0 +1,50 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using NServiceBus.Core.Analyzer; + +public sealed partial class FunctionEndpointGenerator +{ + /// + /// Defines the transport-specific identity for code generation. To add a new transport, + /// create a that calls + /// with a new , + /// and implement a corresponding message processor and AddNServiceBusFunction extension. + /// Transport filtering uses TryGet in the parser: if the trigger attribute type is not + /// in the compilation, the pipeline bails immediately with no output. + /// + /// + /// Current assumptions: + /// + /// Queue/entity name is the first constructor argument of the trigger attribute. + /// Connection name is a named property on the trigger attribute, identified by . + /// Trigger method signatures are validated against . + /// + /// + internal sealed record TriggerDefinition( + string TriggerAttributeMetadataName, + ImmutableEquatableArray AdditionalParameterTypes, + string ProcessorTypeFullyQualified, + string ConnectionPropertyName, + string ProcessMethodName, + TriggerShape Shape); + + internal readonly record struct AdditionalParameterType(string MetadataName, ParameterRole Role) : IEquatable; + + internal readonly record struct TriggerShape(ImmutableEquatableArray OrderedParameters, bool AllowAdditionalParameters) + { + public static TriggerShape Required(params ParameterRole[] orderedParameters) + => new(orderedParameters.ToImmutableEquatableArray(), AllowAdditionalParameters: false); + + public static TriggerShape RequiredAllowingAdditionalParameters(params ParameterRole[] orderedParameters) + => new(orderedParameters.ToImmutableEquatableArray(), AllowAdditionalParameters: true); + } + + internal readonly record struct ParameterRole(string Name) : IEquatable + { + public static readonly ParameterRole TriggerMessage = new("TriggerMessage"); + public static readonly ParameterRole FunctionContext = new("FunctionContext"); + public static readonly ParameterRole CancellationToken = new("CancellationToken"); + + public override string ToString() => Name; + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs index a89fb11..0d894cb 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs @@ -7,12 +7,15 @@ namespace NServiceBus.AzureFunctions.Analyzer; public sealed partial class FunctionEndpointGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) + => InitializeGenerator(context, AzureServiceBusTrigger); + + internal static void InitializeGenerator(IncrementalGeneratorInitializationContext context, TriggerDefinition triggerDefinition) { var extractionResults = context.SyntaxProvider .ForAttributeWithMetadataName( "NServiceBus.NServiceBusFunctionAttribute", predicate: static (node, _) => node is ClassDeclarationSyntax or MethodDeclarationSyntax, - transform: static (ctx, ct) => Parser.Extract(ctx, ct)) + transform: (ctx, ct) => Parser.Extract(ctx, triggerDefinition, ct)) .WithTrackingName(TrackingNames.Extraction); var diagnostics = extractionResults diff --git a/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj b/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj index 13b3bb7..92204ab 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj +++ b/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj @@ -11,6 +11,10 @@ 14.0 + + + + diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/Class1.cs b/src/NServiceBus.AzureFunctions.CodeFixes/Class1.cs deleted file mode 100644 index 0d514fb..0000000 --- a/src/NServiceBus.AzureFunctions.CodeFixes/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NServiceBus.AzureFunctions.CodeFixes -{ - public class Class1 - { - - } -} diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs b/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs new file mode 100644 index 0000000..a3378b3 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs @@ -0,0 +1,131 @@ +namespace NServiceBus.AzureFunctions.CodeFixes; + +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class InvalidFunctionMethodCodeFixProvider : CodeFixProvider +{ + const string DiagnosticId = "NSBFUNC007"; + + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticId); + + public override FixAllProvider? GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var diagnosticSpan = diagnostic.Location.SourceSpan; + var node = root.FindNode(diagnosticSpan); + + var methodDeclaration = node.FirstAncestorOrSelf(); + if (methodDeclaration is null) + { + return; + } + + var classDeclaration = methodDeclaration.FirstAncestorOrSelf(); + if (classDeclaration is null) + { + return; + } + + var properties = diagnostic.Properties; + var hasFixes = properties.ContainsKey("MissingFunctionContext") + || properties.ContainsKey("MissingCancellationToken") + || properties.ContainsKey("MissingMessageActions") + || properties.ContainsKey("MissingConfigureMethod"); + + if (!hasFixes) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Fix NServiceBus function method", + createChangedDocument: ct => FixFunctionMethod(context.Document, root, methodDeclaration, classDeclaration, properties, ct), + equivalenceKey: DiagnosticId), + diagnostic); + } + + static Task FixFunctionMethod( + Document document, + SyntaxNode root, + MethodDeclarationSyntax methodDeclaration, + ClassDeclarationSyntax classDeclaration, + ImmutableDictionary properties, + CancellationToken cancellationToken) + { + var newMethodDeclaration = methodDeclaration; + var newClassDeclaration = classDeclaration; + + // Add missing parameters to the method signature + var paramsToAdd = new List(); + + if (properties.ContainsKey("MissingMessageActions") && properties.TryGetValue("MissingMessageActions", out var messageActionsType) && messageActionsType is not null) + { + paramsToAdd.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("messageActions")) + .WithType(SyntaxFactory.ParseTypeName(messageActionsType))); + } + + if (properties.ContainsKey("MissingFunctionContext")) + { + paramsToAdd.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("context")) + .WithType(SyntaxFactory.ParseTypeName("FunctionContext"))); + } + + if (properties.ContainsKey("MissingCancellationToken")) + { + paramsToAdd.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("cancellationToken")) + .WithType(SyntaxFactory.ParseTypeName("CancellationToken"))); + } + + if (paramsToAdd.Count > 0) + { + var existingParams = newMethodDeclaration.ParameterList.Parameters; + var allParams = existingParams.AddRange(paramsToAdd); + newMethodDeclaration = newMethodDeclaration.WithParameterList( + newMethodDeclaration.ParameterList.WithParameters(allParams)); + } + + // Replace the method in the class + newClassDeclaration = newClassDeclaration.ReplaceNode(methodDeclaration, newMethodDeclaration); + + // Add Configure method if missing + if (properties.ContainsKey("MissingConfigureMethod") + && properties.TryGetValue("ConfigureMethodName", out var configureMethodName) + && configureMethodName is not null) + { + var configureMethod = SyntaxFactory.MethodDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), + SyntaxFactory.Identifier(configureMethodName)) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.StaticKeyword))) + .WithParameterList(SyntaxFactory.ParameterList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("endpointConfiguration")) + .WithType(SyntaxFactory.ParseTypeName("EndpointConfiguration"))))) + .WithBody(SyntaxFactory.Block()) + .NormalizeWhitespace(); + + newClassDeclaration = newClassDeclaration.AddMembers(configureMethod); + } + + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj b/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj index 4553bad..469266e 100644 --- a/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj +++ b/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj @@ -16,4 +16,5 @@ + diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt new file mode 100644 index 0000000..f84d1f4 --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt @@ -0,0 +1,50 @@ +// == Compilation Diagnostics ========================================================================================== +GlobalUsings.cs(4,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. +NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.LenientNoMessageActionsGenerator/FunctionMethodBodies.g.cs(15,55): error CS0234: The type or namespace name 'TestProcessor' does not exist in the namespace 'Demo.Testing' (are you missing an assembly reference?) +Source01.cs(7,18): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.LenientNoMessageActionsGenerator/FunctionMethodBodies.g.cs == +// +using Microsoft.Extensions.DependencyInjection; + +namespace Demo.Testing +{ + public partial class Functions + { + public partial global::System.Threading.Tasks.Task Run( + string message, + global::Demo.Testing.SomeCustomParameter extraParam, + global::Microsoft.Azure.Functions.Worker.FunctionContext context, + global::System.Threading.CancellationToken cancellationToken) + { + var processor = context.InstanceServices + .GetKeyedService("ProcessOrder"); + if (processor is null) + { + throw new global::System.InvalidOperationException("ProcessOrder has not been registered."); + } + return processor.Process(message, context, cancellationToken); + } + } +} + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.LenientNoMessageActionsGenerator/FunctionRegistration.g.cs == +// +namespace NServiceBus.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] + public static class GeneratedFunctionRegistrations_GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters_eb6e20d89fd29573 + { + public static global::System.Collections.Generic.IEnumerable + GetFunctionManifests() + { + yield return new global::NServiceBus.FunctionManifest( + "ProcessOrder", "sales-queue", "StorageConn", + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration)); + yield break; + } + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithExtraUnrecognizedParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithExtraUnrecognizedParameters.approved.txt new file mode 100644 index 0000000..1788608 --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithExtraUnrecognizedParameters.approved.txt @@ -0,0 +1,50 @@ +// == Compilation Diagnostics ========================================================================================== +GlobalUsings.cs(4,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. +NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionMethodBodies.g.cs(15,55): error CS0234: The type or namespace name 'TestProcessor' does not exist in the namespace 'Demo.Testing' (are you missing an assembly reference?) +Source01.cs(7,18): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionMethodBodies.g.cs == +// +using Microsoft.Extensions.DependencyInjection; + +namespace Demo.Testing +{ + public partial class Functions + { + public partial global::System.Threading.Tasks.Task Run( + string message, + global::Demo.Testing.SomeCustomParameter extraParam, + global::Microsoft.Azure.Functions.Worker.FunctionContext context, + global::System.Threading.CancellationToken cancellationToken) + { + var processor = context.InstanceServices + .GetKeyedService("ProcessOrder"); + if (processor is null) + { + throw new global::System.InvalidOperationException("ProcessOrder has not been registered."); + } + return processor.Process(message, context, cancellationToken); + } + } +} + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionRegistration.g.cs == +// +namespace NServiceBus.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] + public static class GeneratedFunctionRegistrations_GeneratesEndpointWithExtraUnrecognizedParameters_fbd0f3c73fa75003 + { + public static global::System.Collections.Generic.IEnumerable + GetFunctionManifests() + { + yield return new global::NServiceBus.FunctionManifest( + "ProcessOrder", "sales-queue", "StorageConn", + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration)); + yield break; + } + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt new file mode 100644 index 0000000..d0edf45 --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt @@ -0,0 +1,49 @@ +// == Compilation Diagnostics ========================================================================================== +GlobalUsings.cs(4,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. +GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. +NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionMethodBodies.g.cs(14,55): error CS0234: The type or namespace name 'TestProcessor' does not exist in the namespace 'Demo.Testing' (are you missing an assembly reference?) +Source01.cs(7,18): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionMethodBodies.g.cs == +// +using Microsoft.Extensions.DependencyInjection; + +namespace Demo.Testing +{ + public partial class Functions + { + public partial global::System.Threading.Tasks.Task Run( + string message, + global::Microsoft.Azure.Functions.Worker.FunctionContext context, + global::System.Threading.CancellationToken cancellationToken) + { + var processor = context.InstanceServices + .GetKeyedService("ProcessOrder"); + if (processor is null) + { + throw new global::System.InvalidOperationException("ProcessOrder has not been registered."); + } + return processor.Process(message, context, cancellationToken); + } + } +} + +// == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionRegistration.g.cs == +// +namespace NServiceBus.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] + public static class GeneratedFunctionRegistrations_GeneratesEndpointWithoutMessageActions_18234b41cd275b7a + { + public static global::System.Collections.Generic.IEnumerable + GetFunctionManifests() + { + yield return new global::NServiceBus.FunctionManifest( + "ProcessOrder", "sales-queue", "StorageConn", + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration)); + yield break; + } + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.ProducesNoEndpointWhenTriggerEntityNameMissing.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.ProducesNoEndpointWhenTriggerEntityNameMissing.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs new file mode 100644 index 0000000..624cdee --- /dev/null +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs @@ -0,0 +1,45 @@ +namespace NServiceBus.AzureFunctions.Analyzers.Tests; + +using Particular.AnalyzerTesting; +using NUnit.Framework; + +[TestFixture] +public class FunctionEndpointGeneratorLenientShapeTests +{ + [Test] + public void GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(SourceWithAdditionalParameter) + .SuppressCompilationErrors() + .Approve(); + + const string SourceWithAdditionalParameter = """ + namespace Demo.Testing; + + [System.AttributeUsage(System.AttributeTargets.Parameter)] + public class TestTriggerAttribute : System.Attribute + { + public TestTriggerAttribute(string queueName) { } + public string? ConnSetting { get; set; } + public bool AutoCompleteMessages { get; set; } + } + + public class SomeCustomParameter { } + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + SomeCustomParameter extraParam, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """; +} diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index aa9a1b1..c7b2de5 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -15,13 +15,49 @@ public void GeneratesFunctionEndpoint() => .SuppressCompilationErrors() .Approve(); + [Test] + public void GeneratesEndpointWithoutMessageActions() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.NoMessageActionsFunction) + .SuppressCompilationErrors() + .Approve(); + + [Test] + public void ReportsInvalidFunctionMethodWhenShapeContainsExtraUnrecognizedParameters() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public class SomeCustomParameter { } + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + SomeCustomParameter extraParam, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("parameters must match required shape")); + } + + #region Structural diagnostics (NSBFUNC001-006) + [TestCase(FunctionClassMustBePartial, DiagnosticIds.ClassMustBePartial)] [TestCase(FunctionClassShouldNotImplementIHandleMessages, DiagnosticIds.ShouldNotImplementIHandleMessages)] [TestCase(FunctionMethodMustBePartial, DiagnosticIds.MethodMustBePartial)] [TestCase(MultipleConfigureMethods, DiagnosticIds.MultipleConfigureMethods)] [TestCase(MissingAutoComplete, DiagnosticIds.AutoCompleteEnabled)] [TestCase(AutoCompleteEnabled, DiagnosticIds.AutoCompleteEnabled)] - public void ReportsGeneratorDiagnostics(string source, string diagnosticId) + public void ReportsStructuralDiagnostics(string source, string diagnosticId) { var result = SourceGeneratorTest.ForIncrementalGenerator() .WithSource(source) @@ -169,4 +205,269 @@ public static void ConfigureProcessOrder(EndpointConfiguration endpointConfigura } } """; + + #endregion + + #region Invalid function method diagnostic (NSBFUNC007) + + [Test] + public void ReportsInvalidFunctionMethodWhenTriggerParameterMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + namespace Demo; + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("missing a parameter with a trigger attribute")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenMessageActionsRequiredButMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + namespace Demo; + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [ServiceBusTrigger("sales-queue", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("missing MessageActions parameter")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenParameterOrderDoesNotMatchShape() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + FunctionContext context, + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("parameters must match required shape")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenMultipleTriggerParametersExist() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string duplicate, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare exactly one trigger parameter")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenFunctionContextMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("missing FunctionContext parameter")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenCancellationTokenMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + FunctionContext context); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("missing CancellationToken parameter")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenConfigureMethodMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + FunctionContext context, + CancellationToken cancellationToken); + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("missing 'ConfigureProcessOrder' configuration method")); + } + + [Test] + public void ReportsInvalidFunctionMethodWhenTriggerEntityNameMissing() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource( + triggerHasConstructor: false, + classBody: """ + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger(ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """)); + + Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify a queue or entity name")); + } + + [Test] + public void ReportsAllProblemsInSingleDiagnostic() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic( + NoMessageActionsSource(""" + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message); + } + """)); + + var message = diagnostic.GetMessage(); + Assert.That(message, Does.Contain("missing FunctionContext parameter")); + Assert.That(message, Does.Contain("missing CancellationToken parameter")); + Assert.That(message, Does.Contain("missing 'ConfigureProcessOrder' configuration method")); + } + + [Test] + public void DoesNotReportMissingMessageActionsWhenTransportDoesNotRequireThem() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.NoMessageActionsFunction) + .SuppressCompilationErrors() + .Run(); + + var diagnostics = result.GetGeneratorDiagnostics(); + Assert.That(diagnostics, Has.None.Matches(d => d.Id == "NSBFUNC007")); + } + + #endregion + + #region Helpers + + static Diagnostic GetInvalidFunctionMethodDiagnostic(string source) where TGenerator : IIncrementalGenerator, new() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(source) + .SuppressCompilationErrors() + .SuppressDiagnosticErrors() + .Run(); + + var diagnostics = result.GetGeneratorDiagnostics(); + Assert.That(diagnostics, Has.Some.Matches(d => d.Id == "NSBFUNC007"), + "Expected NSBFUNC007 diagnostic to be reported"); + + return diagnostics.First(d => d.Id == "NSBFUNC007"); + } + + static string NoMessageActionsSource(string classBody, bool triggerHasConstructor = true) + { + var constructor = triggerHasConstructor + ? "public TestTriggerAttribute(string queueName) { }" + : ""; + + return $$""" + namespace Demo.Testing; + + [System.AttributeUsage(System.AttributeTargets.Parameter)] + public class TestTriggerAttribute : System.Attribute + { + {{constructor}} + public string? ConnSetting { get; set; } + public bool AutoCompleteMessages { get; set; } + } + + {{classBody}} + """; + } + + #endregion } \ No newline at end of file diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs new file mode 100644 index 0000000..ed2913a --- /dev/null +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -0,0 +1,32 @@ +namespace NServiceBus.AzureFunctions.Analyzers.Tests; + +using Microsoft.CodeAnalysis; +using NServiceBus.AzureFunctions.Analyzer; +using NServiceBus.Core.Analyzer; + +class LenientNoMessageActionsGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + => FunctionEndpointGenerator.InitializeGenerator(context, + new FunctionEndpointGenerator.TriggerDefinition( + TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", + AdditionalParameterTypes: ImmutableEquatableArray.Empty, + ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", + ConnectionPropertyName: "ConnSetting", + ProcessMethodName: "Process", + Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( + FunctionEndpointGenerator.ParameterRole.TriggerMessage, + FunctionEndpointGenerator.ParameterRole.FunctionContext, + FunctionEndpointGenerator.ParameterRole.CancellationToken))); + + internal static class TrackingNames + { + public const string Extraction = nameof(Extraction); + public const string Diagnostics = nameof(Diagnostics); + public const string Functions = nameof(Functions); + public const string AssemblyClassName = nameof(AssemblyClassName); + public const string Combined = nameof(Combined); + + public static string[] All => [Extraction, Diagnostics, Functions, AssemblyClassName, Combined]; + } +} diff --git a/src/Tests.Analyzers/NServiceBus.AzureFunctions.Analyzers.Tests.csproj b/src/Tests.Analyzers/NServiceBus.AzureFunctions.Analyzers.Tests.csproj index f9640b3..383c813 100644 --- a/src/Tests.Analyzers/NServiceBus.AzureFunctions.Analyzers.Tests.csproj +++ b/src/Tests.Analyzers/NServiceBus.AzureFunctions.Analyzers.Tests.csproj @@ -2,6 +2,8 @@ net10.0 + true + ..\NServiceBusTests.snk @@ -21,8 +23,4 @@ - - - - diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs new file mode 100644 index 0000000..1345a53 --- /dev/null +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -0,0 +1,36 @@ +namespace NServiceBus.AzureFunctions.Analyzers.Tests; + +using Microsoft.CodeAnalysis; +using NServiceBus.AzureFunctions.Analyzer; +using NServiceBus.Core.Analyzer; + +/// +/// Test generator that exercises a transport with no additional parameters. +/// Uses a custom trigger attribute without transport-specific parameters. +/// +class NoMessageActionsGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + => FunctionEndpointGenerator.InitializeGenerator(context, + new FunctionEndpointGenerator.TriggerDefinition( + TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", + AdditionalParameterTypes: ImmutableEquatableArray.Empty, + ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", + ConnectionPropertyName: "ConnSetting", + ProcessMethodName: "Process", + Shape: FunctionEndpointGenerator.TriggerShape.Required( + FunctionEndpointGenerator.ParameterRole.TriggerMessage, + FunctionEndpointGenerator.ParameterRole.FunctionContext, + FunctionEndpointGenerator.ParameterRole.CancellationToken))); + + internal static class TrackingNames + { + public const string Extraction = nameof(Extraction); + public const string Diagnostics = nameof(Diagnostics); + public const string Functions = nameof(Functions); + public const string AssemblyClassName = nameof(AssemblyClassName); + public const string Combined = nameof(Combined); + + public static string[] All => [Extraction, Diagnostics, Functions, AssemblyClassName, Combined]; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/TestSources.cs b/src/Tests.Analyzers/TestSources.cs index 4f28f47..b9b9870 100644 --- a/src/Tests.Analyzers/TestSources.cs +++ b/src/Tests.Analyzers/TestSources.cs @@ -23,4 +23,31 @@ public static void ConfigureProcessOrder( } } """; + + public const string NoMessageActionsFunction = """ + namespace Demo.Testing; + + [System.AttributeUsage(System.AttributeTargets.Parameter)] + public class TestTriggerAttribute : System.Attribute + { + public TestTriggerAttribute(string queueName) { } + public string? ConnSetting { get; set; } + public bool AutoCompleteMessages { get; set; } + } + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [TestTrigger("sales-queue", ConnSetting = "StorageConn", AutoCompleteMessages = false)] string message, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder( + EndpointConfiguration endpointConfiguration) + { + } + } + """; } \ No newline at end of file From dbf0883325fa76d5bf308f88a2690e9893e7f3b9 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Tue, 31 Mar 2026 16:15:59 -0500 Subject: [PATCH 02/14] Share DiagnosticIds between Analyzer and CodeFixes projects. FIXES constant. --- src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs | 5 ++++- .../MissingConfigureMethodCodeFixProvider.cs | 7 +++---- .../NServiceBus.AzureFunctions.CodeFixes.csproj | 5 +++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index ae87666..2f573b4 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -2,7 +2,10 @@ namespace NServiceBus.AzureFunctions.Analyzer; using Microsoft.CodeAnalysis; -public static class DiagnosticIds +#if !FIXES +public +#endif +static class DiagnosticIds { public const string ClassMustBePartial = "NSBFUNC001"; public const string ShouldNotImplementIHandleMessages = "NSBFUNC002"; diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs b/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs index a3378b3..9df055a 100644 --- a/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs +++ b/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs @@ -7,14 +7,13 @@ namespace NServiceBus.AzureFunctions.CodeFixes; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using NServiceBus.AzureFunctions.Analyzer; [ExportCodeFixProvider(LanguageNames.CSharp), Shared] public sealed class InvalidFunctionMethodCodeFixProvider : CodeFixProvider { - const string DiagnosticId = "NSBFUNC007"; - public override ImmutableArray FixableDiagnosticIds => - ImmutableArray.Create(DiagnosticId); + ImmutableArray.Create(DiagnosticIds.InvalidFunctionMethod); public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; @@ -58,7 +57,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) CodeAction.Create( title: "Fix NServiceBus function method", createChangedDocument: ct => FixFunctionMethod(context.Document, root, methodDeclaration, classDeclaration, properties, ct), - equivalenceKey: DiagnosticId), + equivalenceKey: DiagnosticIds.InvalidFunctionMethod), diagnostic); } diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj b/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj index 469266e..64add21 100644 --- a/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj +++ b/src/NServiceBus.AzureFunctions.CodeFixes/NServiceBus.AzureFunctions.CodeFixes.csproj @@ -9,8 +9,13 @@ false true 14.0 + $(DefineConstants);FIXES + + + + From 32b62a1e45dd2d33786f83816992b5e0046ae712 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Tue, 31 Mar 2026 16:18:10 -0500 Subject: [PATCH 03/14] File rename --- ...CodeFixProvider.cs => InvalidFunctionMethodCodeFixProvider.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/NServiceBus.AzureFunctions.CodeFixes/{MissingConfigureMethodCodeFixProvider.cs => InvalidFunctionMethodCodeFixProvider.cs} (100%) diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs b/src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs similarity index 100% rename from src/NServiceBus.AzureFunctions.CodeFixes/MissingConfigureMethodCodeFixProvider.cs rename to src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs From 77c0f632dad813b85e3e81025ce84b7591ff71de Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Wed, 1 Apr 2026 13:08:06 -0500 Subject: [PATCH 04/14] Transport-agnostic model/pipeline changes --- ...nctionEndpointGenerator.AzureServiceBus.cs | 4 ++- .../FunctionEndpointGenerator.Emitter.cs | 4 +-- .../FunctionEndpointGenerator.Parser.cs | 31 ++++++++++--------- ...tionEndpointGenerator.TriggerDefinition.cs | 10 +++--- ...nctionsHostApplicationBuilderExtensions.cs | 4 +-- .../FunctionEndpointConfigurationBuilder.cs | 6 ++-- .../FunctionManifest.cs | 2 +- .../FunctionEndpointGeneratorTests.cs | 4 +-- .../LenientNoMessageActionsGenerator.cs | 2 ++ .../NoMessageActionsGenerator.cs | 4 ++- 10 files changed, 41 insertions(+), 30 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index 3c26848..1079848 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -14,10 +14,12 @@ public sealed partial class FunctionEndpointGenerator }.ToImmutableEquatableArray(), ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", ConnectionPropertyName: "Connection", + AutoCompletePropertyName: "AutoCompleteMessages", + RequireAutoCompleteFalse: true, ProcessMethodName: "Process", Shape: TriggerShape.Required( ParameterRole.TriggerMessage, MessageActions, ParameterRole.FunctionContext, ParameterRole.CancellationToken)); -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs index 2fdbb24..a355e6e 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs @@ -97,7 +97,7 @@ static void EmitRegistration(SourceProductionContext spc, ImmutableArray f.FunctionName, StringComparer.Ordinal)) { writer.WriteLine("yield return new global::NServiceBus.FunctionManifest("); - writer.WriteLine($" \"{func.FunctionName}\", \"{func.QueueName}\", \"{func.ConnectionName}\","); + writer.WriteLine($" \"{func.FunctionName}\", \"{func.AddressName}\", \"{func.ConnectionSettingName}\","); writer.WriteLine($" {GenerateConfigureMethodCall(func.ConfigureMethod)});"); } @@ -113,4 +113,4 @@ static string GenerateConfigureMethodCall(ConfigureMethodSpec configureMethod) return $"(endpointconfiguration, iconfiguration, ihostenvironment) => {configureMethod.ContainingTypeFullyQualified}.{configureMethod.MethodName}({argumentList})"; } } -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index cd8a141..675778e 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -97,8 +97,8 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo return null; } - string? queueName = null; - string? connectionName = null; + string? addressName = null; + string? connectionSettingName = null; string? messageParamName = null; string? functionContextParamName = null; string? cancellationTokenParamName = null; @@ -134,25 +134,28 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo if (pAttr.ConstructorArguments.Length > 0) { - queueName = pAttr.ConstructorArguments[0].Value as string; + addressName = pAttr.ConstructorArguments[0].Value as string; } - var autoCompleteEnabled = true; + var autoCompleteEnabled = triggerDefinition.RequireAutoCompleteFalse; foreach (var namedArg in pAttr.NamedArguments) { - if (namedArg.Key == triggerDefinition.ConnectionPropertyName) + if (triggerDefinition.ConnectionPropertyName is not null + && namedArg.Key == triggerDefinition.ConnectionPropertyName) { - connectionName = namedArg.Value.Value as string; + connectionSettingName = namedArg.Value.Value as string; } - if (namedArg.Key == "AutoCompleteMessages") + if (triggerDefinition.RequireAutoCompleteFalse + && triggerDefinition.AutoCompletePropertyName is not null + && namedArg.Key == triggerDefinition.AutoCompletePropertyName) { var autoComplete = namedArg.Value.Value as bool?; autoCompleteEnabled = autoComplete!.Value; } } - if (autoCompleteEnabled) + if (triggerDefinition.RequireAutoCompleteFalse && autoCompleteEnabled) { diagnostics.Add(CreateDiagnostic(DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, method, method.Name)); } @@ -189,9 +192,9 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo problems.Add("must declare exactly one trigger parameter"); } - if (messageParamName is not null && queueName is null) + if (messageParamName is not null && addressName is null) { - problems.Add("trigger attribute does not specify a queue or entity name"); + problems.Add("trigger attribute does not specify an address or entity name"); } if (functionContextParamName is null) @@ -253,7 +256,7 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo return null; } - connectionName ??= ""; + connectionSettingName ??= ""; var ns = containingType.ContainingNamespace.ToDisplayString(); var className = containingType.Name; @@ -297,7 +300,7 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo return new FunctionSpec( ns, className, accessibility, method.Name, returnType, paramList.ToString(), functionContextParamName!, - functionName, queueName!, connectionName, + functionName, addressName!, connectionSettingName, triggerDefinition.ProcessorTypeFullyQualified, processCallExpression, configureMethod!.Value); } @@ -506,8 +509,8 @@ internal sealed record FunctionSpec( string ParameterList, string FunctionContextParamName, string FunctionName, - string QueueName, - string ConnectionName, + string AddressName, + string ConnectionSettingName, string ProcessorTypeFullyQualified, string ProcessCallExpression, ConfigureMethodSpec ConfigureMethod); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs index cb6befc..08553ea 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -15,8 +15,8 @@ public sealed partial class FunctionEndpointGenerator /// /// Current assumptions: /// - /// Queue/entity name is the first constructor argument of the trigger attribute. - /// Connection name is a named property on the trigger attribute, identified by . + /// Address/entity name is the first constructor argument of the trigger attribute. + /// Connection setting name can be read from a named trigger property, if is configured. /// Trigger method signatures are validated against . /// /// @@ -24,7 +24,9 @@ internal sealed record TriggerDefinition( string TriggerAttributeMetadataName, ImmutableEquatableArray AdditionalParameterTypes, string ProcessorTypeFullyQualified, - string ConnectionPropertyName, + string? ConnectionPropertyName, + string? AutoCompletePropertyName, + bool RequireAutoCompleteFalse, string ProcessMethodName, TriggerShape Shape); @@ -47,4 +49,4 @@ internal readonly record struct ParameterRole(string Name) : IEquatable Name; } -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs index 37a39ac..58fffa8 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs @@ -18,7 +18,7 @@ public static void AddNServiceBusFunction(this FunctionsApplicationBuilder build var endpointConfiguration = FunctionEndpointConfigurationBuilder.BuildReceiveEndpointConfiguration(builder, functionManifest, nameof(AddSendOnlyNServiceBusEndpoint)); var transport = GetAzureServiceBusTransport(endpointConfiguration.GetSettings()); - transport.ConnectionName = functionManifest.ConnectionName; + transport.ConnectionName = functionManifest.ConnectionSettingName; builder.Services.AddNServiceBusEndpoint(endpointConfiguration); builder.Services.AddKeyedSingleton(functionManifest.Name, (_, _) => new AzureServiceBusMessageProcessor(transport, functionManifest.Name)); } @@ -40,4 +40,4 @@ static AzureServiceBusServerlessTransport GetAzureServiceBusTransport(SettingsHo return transport ?? throw new InvalidOperationException($"Endpoint must be configured with an {nameof(AzureServiceBusServerlessTransport)}."); } -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions/FunctionEndpointConfigurationBuilder.cs b/src/NServiceBus.AzureFunctions/FunctionEndpointConfigurationBuilder.cs index a0f0b58..448ad4f 100644 --- a/src/NServiceBus.AzureFunctions/FunctionEndpointConfigurationBuilder.cs +++ b/src/NServiceBus.AzureFunctions/FunctionEndpointConfigurationBuilder.cs @@ -25,9 +25,9 @@ public static EndpointConfiguration BuildReceiveEndpointConfiguration( throw new InvalidOperationException($"Functions can't be send only endpoints, use {sendOnlyEndpointApiName}"); } - if (functionManifest.Name != functionManifest.Queue) + if (functionManifest.Name != functionManifest.Address) { - endpointConfiguration.OverrideLocalAddress(functionManifest.Queue); + endpointConfiguration.OverrideLocalAddress(functionManifest.Address); } return endpointConfiguration; @@ -50,4 +50,4 @@ public static EndpointConfiguration BuildSendOnlyEndpointConfiguration( } const string SendOnlyConfigKey = "Endpoint.SendOnly"; -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions/FunctionManifest.cs b/src/NServiceBus.AzureFunctions/FunctionManifest.cs index 4950268..2eeab97 100644 --- a/src/NServiceBus.AzureFunctions/FunctionManifest.cs +++ b/src/NServiceBus.AzureFunctions/FunctionManifest.cs @@ -1,3 +1,3 @@ namespace NServiceBus; -public sealed record FunctionManifest(string Name, string Queue, string ConnectionName, FunctionEndpointConfiguration Configuration); \ No newline at end of file +public sealed record FunctionManifest(string Name, string Address, string ConnectionSettingName, FunctionEndpointConfiguration Configuration); diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index c7b2de5..c1b3d24 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -394,7 +394,7 @@ public static void ConfigureProcessOrder( } """)); - Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify a queue or entity name")); + Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify an address or entity name")); } [Test] @@ -470,4 +470,4 @@ public class TestTriggerAttribute : System.Attribute } #endregion -} \ No newline at end of file +} diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index ed2913a..d3fd3d6 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -13,6 +13,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", ConnectionPropertyName: "ConnSetting", + AutoCompletePropertyName: null, + RequireAutoCompleteFalse: false, ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( FunctionEndpointGenerator.ParameterRole.TriggerMessage, diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 1345a53..707e549 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -17,6 +17,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", ConnectionPropertyName: "ConnSetting", + AutoCompletePropertyName: null, + RequireAutoCompleteFalse: false, ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.Required( FunctionEndpointGenerator.ParameterRole.TriggerMessage, @@ -33,4 +35,4 @@ internal static class TrackingNames public static string[] All => [Extraction, Diagnostics, Functions, AssemblyClassName, Combined]; } -} \ No newline at end of file +} From d5eb3881edc88d1e9fac3cbe4cc6b376580ef4b2 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Wed, 1 Apr 2026 13:09:55 -0500 Subject: [PATCH 05/14] Harden diagnostics and code fix for transport-neutral triggers --- .../DiagnosticIds.cs | 4 +-- .../InvalidFunctionMethodCodeFixProvider.cs | 27 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 2f573b4..60ba5bd 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -50,7 +50,7 @@ static class DiagnosticIds internal static readonly DiagnosticDescriptor AutoCompleteMustBeExplicitlyDisabled = new( id: AutoCompleteEnabled, title: "Message auto completion must be explicitly disabled", - messageFormat: "The auto complete property on the service bus trigger for method '{0}' must be explicitly set to false", + messageFormat: "The trigger auto complete property for method '{0}' must be explicitly set to false", category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -71,4 +71,4 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, customTags: [WellKnownDiagnosticTags.CompilationEnd]); -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs b/src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs index 9df055a..fd8b812 100644 --- a/src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs +++ b/src/NServiceBus.AzureFunctions.CodeFixes/InvalidFunctionMethodCodeFixProvider.cs @@ -1,7 +1,9 @@ namespace NServiceBus.AzureFunctions.CodeFixes; +using System; using System.Collections.Immutable; using System.Composition; +using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; @@ -43,9 +45,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) } var properties = diagnostic.Properties; + var hasMissingAdditionalParameter = properties.Any(kvp => kvp.Key.StartsWith("Missing", StringComparison.Ordinal) + && kvp.Key is not "MissingFunctionContext" + && kvp.Key is not "MissingCancellationToken" + && kvp.Key is not "MissingConfigureMethod"); var hasFixes = properties.ContainsKey("MissingFunctionContext") || properties.ContainsKey("MissingCancellationToken") - || properties.ContainsKey("MissingMessageActions") + || hasMissingAdditionalParameter || properties.ContainsKey("MissingConfigureMethod"); if (!hasFixes) @@ -75,10 +81,21 @@ static Task FixFunctionMethod( // Add missing parameters to the method signature var paramsToAdd = new List(); - if (properties.ContainsKey("MissingMessageActions") && properties.TryGetValue("MissingMessageActions", out var messageActionsType) && messageActionsType is not null) + foreach (var property in properties + .Where(kvp => kvp.Key.StartsWith("Missing", StringComparison.Ordinal) + && kvp.Key is not "MissingFunctionContext" + && kvp.Key is not "MissingCancellationToken" + && kvp.Key is not "MissingConfigureMethod")) { - paramsToAdd.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("messageActions")) - .WithType(SyntaxFactory.ParseTypeName(messageActionsType))); + if (property.Value is null) + { + continue; + } + + var propertySuffix = property.Key.Substring("Missing".Length); + var parameterName = char.ToLowerInvariant(propertySuffix[0]) + propertySuffix.Substring(1); + paramsToAdd.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)) + .WithType(SyntaxFactory.ParseTypeName(property.Value))); } if (properties.ContainsKey("MissingFunctionContext")) @@ -127,4 +144,4 @@ static Task FixFunctionMethod( var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); return Task.FromResult(document.WithSyntaxRoot(newRoot)); } -} \ No newline at end of file +} From 9e8d45e62a9ab970a862d27b6ad2ea57b3a47676 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Wed, 1 Apr 2026 16:04:31 -0500 Subject: [PATCH 06/14] Organize DiagnosticIds --- .../DiagnosticIds.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index 60ba5bd..af66194 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -39,6 +39,15 @@ static class DiagnosticIds defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + internal static readonly DiagnosticDescriptor MissingAddNServiceBusFunctionsCallDescriptor = new( + id: MissingAddNServiceBusFunctionsCall, + title: "AddNServiceBusFunctions() is not called", + messageFormat: "This project has NServiceBus endpoint registrations but does not call builder.AddNServiceBusFunctions(). Endpoints will not be started.", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + customTags: [WellKnownDiagnosticTags.CompilationEnd]); + internal static readonly DiagnosticDescriptor MultipleConfigureMethodsDescriptor = new( id: MultipleConfigureMethods, title: "Multiple configuration methods found", @@ -62,13 +71,4 @@ static class DiagnosticIds category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - - internal static readonly DiagnosticDescriptor MissingAddNServiceBusFunctionsCallDescriptor = new( - id: MissingAddNServiceBusFunctionsCall, - title: "AddNServiceBusFunctions() is not called", - messageFormat: "This project has NServiceBus endpoint registrations but does not call builder.AddNServiceBusFunctions(). Endpoints will not be started.", - category: "NServiceBus.AzureFunctions", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true, - customTags: [WellKnownDiagnosticTags.CompilationEnd]); } From d06132c3877bd25e0e8427acc963a049a371c71b Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Wed, 1 Apr 2026 16:34:16 -0500 Subject: [PATCH 07/14] Introduce transport-aware function registration seams --- .../FunctionCompositionGenerator.Emitter.cs | 4 ++-- .../FunctionEndpointGenerator.AzureServiceBus.cs | 1 + .../FunctionEndpointGenerator.Emitter.cs | 3 ++- .../FunctionEndpointGenerator.Parser.cs | 3 ++- .../FunctionEndpointGenerator.TriggerDefinition.cs | 1 + .../AzureServiceBusFunctionManifestRegistration.cs | 9 +++++++++ src/NServiceBus.AzureFunctions/FunctionManifest.cs | 11 ++++++++++- ...atorTests.GeneratesProjectComposition.approved.txt | 7 ++++--- ...rsWhenShapeAllowsAdditionalParameters.approved.txt | 3 ++- ...eneratesEndpointWithoutMessageActions.approved.txt | 3 ++- ...eratorTests.GeneratesFunctionEndpoint.approved.txt | 3 ++- .../FunctionEndpointGeneratorLenientShapeTests.cs | 5 +++++ src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs | 5 +++++ .../LenientNoMessageActionsGenerator.cs | 1 + src/Tests.Analyzers/NoMessageActionsGenerator.cs | 1 + src/Tests.Analyzers/TestSources.cs | 7 ++++++- ...ls.ApprovaAzureServiceBusComponentApi.approved.txt | 4 ++++ 17 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusFunctionManifestRegistration.cs diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs index 06e6b4a..de410a3 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs @@ -36,11 +36,11 @@ public static void Emit(SourceProductionContext context, CompositionSpec? compos { context.CancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($"foreach (var manifest in global::{registrationClass.FullClassName}.GetFunctionManifests())"); - writer.WriteLine(" global::NServiceBus.FunctionsHostApplicationBuilderExtensions.AddNServiceBusFunction(builder, manifest);"); + writer.WriteLine(" manifest.Register(builder, manifest);"); } writer.CloseCurlies(); context.AddSource(TrackingNames.Composition, writer.ToSourceText()); } } -} \ No newline at end of file +} diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index 1079848..9aba18f 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -16,6 +16,7 @@ public sealed partial class FunctionEndpointGenerator ConnectionPropertyName: "Connection", AutoCompletePropertyName: "AutoCompleteMessages", RequireAutoCompleteFalse: true, + RegistrationMethodFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: TriggerShape.Required( ParameterRole.TriggerMessage, diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs index a355e6e..c8195b4 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Emitter.cs @@ -98,7 +98,8 @@ static void EmitRegistration(SourceProductionContext spc, ImmutableArray + FunctionsHostApplicationBuilderExtensions.AddNServiceBusFunction(builder, functionManifest); +} diff --git a/src/NServiceBus.AzureFunctions/FunctionManifest.cs b/src/NServiceBus.AzureFunctions/FunctionManifest.cs index 2eeab97..9328901 100644 --- a/src/NServiceBus.AzureFunctions/FunctionManifest.cs +++ b/src/NServiceBus.AzureFunctions/FunctionManifest.cs @@ -1,3 +1,12 @@ namespace NServiceBus; -public sealed record FunctionManifest(string Name, string Address, string ConnectionSettingName, FunctionEndpointConfiguration Configuration); +using Microsoft.Azure.Functions.Worker.Builder; + +public delegate void FunctionManifestRegistration(FunctionsApplicationBuilder builder, FunctionManifest functionManifest); + +public sealed record FunctionManifest( + string Name, + string Address, + string ConnectionSettingName, + FunctionEndpointConfiguration Configuration, + FunctionManifestRegistration Register); diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt index 1644343..ec18416 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt @@ -44,9 +44,9 @@ namespace My.FunctionApp global::NServiceBus.NServiceBusFunctionsInfrastructure.Initialize(builder); foreach (var manifest in global::NServiceBus.Generated.GeneratedFunctionRegistrations_GeneratesProjectComposition_4d91953014478208.GetFunctionManifests()) - global::NServiceBus.FunctionsHostApplicationBuilderExtensions.AddNServiceBusFunction(builder, manifest); + manifest.Register(builder, manifest); foreach (var manifest in global::NServiceBus.Generated.GeneratedFunctionRegistrations_NServiceBus_AzureFunctions_AzureServiceBus_e560d8ca34e34763.GetFunctionManifests()) - global::NServiceBus.FunctionsHostApplicationBuilderExtensions.AddNServiceBusFunction(builder, manifest); + manifest.Register(builder, manifest); } } } @@ -90,7 +90,8 @@ namespace NServiceBus.Generated { yield return new global::NServiceBus.FunctionManifest( "ProcessOrder", "sales-queue", "AzureServiceBus", - (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Functions.ConfigureProcessOrder(endpointconfiguration, iconfiguration, ihostenvironment)); + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Functions.ConfigureProcessOrder(endpointconfiguration, iconfiguration, ihostenvironment), + global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register); yield break; } } diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt index f84d1f4..7bfb800 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt @@ -43,7 +43,8 @@ namespace NServiceBus.Generated { yield return new global::NServiceBus.FunctionManifest( "ProcessOrder", "sales-queue", "StorageConn", - (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration)); + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration), + global::Demo.Testing.TestFunctionManifestRegistration.Register); yield break; } } diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt index d0edf45..f280d34 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt @@ -42,7 +42,8 @@ namespace NServiceBus.Generated { yield return new global::NServiceBus.FunctionManifest( "ProcessOrder", "sales-queue", "StorageConn", - (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration)); + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Testing.Functions.ConfigureProcessOrder(endpointconfiguration), + global::Demo.Testing.TestFunctionManifestRegistration.Register); yield break; } } diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesFunctionEndpoint.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesFunctionEndpoint.approved.txt index 2fdda0b..7ddb8e1 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesFunctionEndpoint.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesFunctionEndpoint.approved.txt @@ -36,7 +36,8 @@ namespace NServiceBus.Generated { yield return new global::NServiceBus.FunctionManifest( "ProcessOrder", "sales-queue", "AzureServiceBus", - (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Functions.ConfigureProcessOrder(endpointconfiguration, iconfiguration, ihostenvironment)); + (endpointconfiguration, iconfiguration, ihostenvironment) => global::Demo.Functions.ConfigureProcessOrder(endpointconfiguration, iconfiguration, ihostenvironment), + global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register); yield break; } } diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs index 624cdee..cc9e07d 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs @@ -24,6 +24,11 @@ public TestTriggerAttribute(string queueName) { } public bool AutoCompleteMessages { get; set; } } + public static class TestFunctionManifestRegistration + { + public static void Register(global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder _, global::NServiceBus.FunctionManifest __) { } + } + public class SomeCustomParameter { } public partial class Functions diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index c1b3d24..824cf46 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -465,6 +465,11 @@ public class TestTriggerAttribute : System.Attribute public bool AutoCompleteMessages { get; set; } } + public static class TestFunctionManifestRegistration + { + public static void Register(global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder _, global::NServiceBus.FunctionManifest __) { } + } + {{classBody}} """; } diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index d3fd3d6..df5f01f 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -15,6 +15,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ConnectionPropertyName: "ConnSetting", AutoCompletePropertyName: null, RequireAutoCompleteFalse: false, + RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( FunctionEndpointGenerator.ParameterRole.TriggerMessage, diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 707e549..9d222ac 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -19,6 +19,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ConnectionPropertyName: "ConnSetting", AutoCompletePropertyName: null, RequireAutoCompleteFalse: false, + RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.Required( FunctionEndpointGenerator.ParameterRole.TriggerMessage, diff --git a/src/Tests.Analyzers/TestSources.cs b/src/Tests.Analyzers/TestSources.cs index b9b9870..4c698e8 100644 --- a/src/Tests.Analyzers/TestSources.cs +++ b/src/Tests.Analyzers/TestSources.cs @@ -35,6 +35,11 @@ public TestTriggerAttribute(string queueName) { } public bool AutoCompleteMessages { get; set; } } + public static class TestFunctionManifestRegistration + { + public static void Register(global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder _, global::NServiceBus.FunctionManifest __) { } + } + public partial class Functions { [NServiceBusFunction] @@ -50,4 +55,4 @@ public static void ConfigureProcessOrder( } } """; -} \ No newline at end of file +} diff --git a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt index bdddbc5..3b8477e 100644 --- a/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt +++ b/src/Tests/ApprovalFiles/ApiApprovals.ApprovaAzureServiceBusComponentApi.approved.txt @@ -1,6 +1,10 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"NServiceBus.AzureFunctions.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001007f16e21368ff041183fab592d9e8ed37e7be355e93323147a1d29983d6e591b04282e4da0c9e18bd901e112c0033925eb7d7872c2f1706655891c5c9d57297994f707d16ee9a8f40d978f064ee1ffc73c0db3f4712691b23bf596f75130f4ec978cf78757ec034625a5f27e6bb50c618931ea49f6f628fd74271c32959efb1c5")] namespace NServiceBus.AzureFunctions.AzureServiceBus { + public static class AzureServiceBusFunctionManifestRegistration + { + public static void Register(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.FunctionManifest functionManifest) { } + } public class AzureServiceBusMessageProcessor { public AzureServiceBusMessageProcessor(NServiceBus.AzureServiceBusServerlessTransport transport, string endpointName) { } From f0e4103d4b5fb24a888bb6862e0cdd5fafb75b2d Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Thu, 2 Apr 2026 17:05:36 -0500 Subject: [PATCH 08/14] Make function endpoint extraction transform static --- .../FunctionEndpointGenerator.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs index 0d894cb..12b8fdf 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs @@ -11,11 +11,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) internal static void InitializeGenerator(IncrementalGeneratorInitializationContext context, TriggerDefinition triggerDefinition) { - var extractionResults = context.SyntaxProvider + var extractionCandidates = context.SyntaxProvider .ForAttributeWithMetadataName( "NServiceBus.NServiceBusFunctionAttribute", predicate: static (node, _) => node is ClassDeclarationSyntax or MethodDeclarationSyntax, - transform: (ctx, ct) => Parser.Extract(ctx, triggerDefinition, ct)) + transform: static (ctx, _) => ctx); + + var triggerDefinitionProvider = CreateTriggerDefinitionProvider(context, triggerDefinition); + + var extractionResults = extractionCandidates + .Combine(triggerDefinitionProvider) + .Select(static (pair, ct) => Parser.Extract(pair.Left, pair.Right, ct)) .WithTrackingName(TrackingNames.Extraction); var diagnostics = extractionResults @@ -38,5 +44,10 @@ internal static void InitializeGenerator(IncrementalGeneratorInitializationConte .WithTrackingName(TrackingNames.Combined); context.RegisterSourceOutput(combined, static (spc, data) => Emitter.Emit(spc, data.Left, data.Right)); + + static IncrementalValueProvider CreateTriggerDefinitionProvider( + IncrementalGeneratorInitializationContext context, + TriggerDefinition triggerDefinition) => + context.CompilationProvider.Select((_, _) => triggerDefinition); } -} \ No newline at end of file +} From d944b4ede8340cd846ab2db847ebfbb6fb758227 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Thu, 2 Apr 2026 17:12:13 -0500 Subject: [PATCH 09/14] Use immutable builder for parser problem collection --- .../FunctionEndpointGenerator.Parser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index 071286a..f69ac54 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -181,7 +181,7 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo } } - var problems = new List(); + var problems = ImmutableList.CreateBuilder(); if (triggerParameterCount == 0) { From 97236c1b3c0da60c3f3b07d9d79b1a9cec5b2f07 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Fri, 3 Apr 2026 16:37:48 -0500 Subject: [PATCH 10/14] Emitter formatting --- .../FunctionCompositionGenerator.Emitter.cs | 4 +++- ...ionGeneratorTests.GeneratesProjectComposition.approved.txt | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs index de410a3..211ec49 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs @@ -36,11 +36,13 @@ public static void Emit(SourceProductionContext context, CompositionSpec? compos { context.CancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($"foreach (var manifest in global::{registrationClass.FullClassName}.GetFunctionManifests())"); + writer.WriteLine("{"); writer.WriteLine(" manifest.Register(builder, manifest);"); + writer.WriteLine("}"); } writer.CloseCurlies(); context.AddSource(TrackingNames.Composition, writer.ToSourceText()); } } -} +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt index ec18416..50d4dc7 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt @@ -44,9 +44,13 @@ namespace My.FunctionApp global::NServiceBus.NServiceBusFunctionsInfrastructure.Initialize(builder); foreach (var manifest in global::NServiceBus.Generated.GeneratedFunctionRegistrations_GeneratesProjectComposition_4d91953014478208.GetFunctionManifests()) + { manifest.Register(builder, manifest); + } foreach (var manifest in global::NServiceBus.Generated.GeneratedFunctionRegistrations_NServiceBus_AzureFunctions_AzureServiceBus_e560d8ca34e34763.GetFunctionManifests()) + { manifest.Register(builder, manifest); + } } } } From 2f764b03a48645d60c0149f79c7f7e3a7622f204 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Mon, 6 Apr 2026 13:18:54 -0500 Subject: [PATCH 11/14] Refine generator seams with policy-based trigger parsing and clearer diagnostics --- .../DiagnosticIds.cs | 2 +- ...nctionEndpointGenerator.AzureServiceBus.cs | 6 +- .../FunctionEndpointGenerator.Parser.cs | 96 +++++++++++++++---- ...tionEndpointGenerator.TriggerDefinition.cs | 76 +++++++++++++-- .../LenientNoMessageActionsGenerator.cs | 6 +- .../NoMessageActionsGenerator.cs | 6 +- 6 files changed, 157 insertions(+), 35 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index af66194..9972011 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -59,7 +59,7 @@ static class DiagnosticIds internal static readonly DiagnosticDescriptor AutoCompleteMustBeExplicitlyDisabled = new( id: AutoCompleteEnabled, title: "Message auto completion must be explicitly disabled", - messageFormat: "The trigger auto complete property for method '{0}' must be explicitly set to false", + messageFormat: "The '{1}' property on [{2}] for method '{0}' must be explicitly set to false", category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index 9aba18f..9df25b5 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -13,9 +13,9 @@ public sealed partial class FunctionEndpointGenerator new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) }.ToImmutableEquatableArray(), ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", - ConnectionPropertyName: "Connection", - AutoCompletePropertyName: "AutoCompleteMessages", - RequireAutoCompleteFalse: true, + AddressExtraction: AddressExtractionPolicy.FromConstructorArgument(0), + ConnectionSetting: ConnectionSettingPolicy.FromNamedProperty("Connection"), + AutoComplete: AutoCompletePolicy.MustBeFalseFor("AutoCompleteMessages"), RegistrationMethodFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: TriggerShape.Required( diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index f69ac54..717b46d 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -132,32 +132,25 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo messageParamName = param.Name; } - if (pAttr.ConstructorArguments.Length > 0) + if (TryExtractAddress(pAttr, triggerDefinition.AddressExtraction, out var extractedAddress)) { - addressName = pAttr.ConstructorArguments[0].Value as string; + addressName = extractedAddress; } - var autoCompleteEnabled = triggerDefinition.RequireAutoCompleteFalse; - foreach (var namedArg in pAttr.NamedArguments) + if (TryExtractConnectionSetting(pAttr, triggerDefinition.ConnectionSetting, out var extractedConnectionSetting)) { - if (triggerDefinition.ConnectionPropertyName is not null - && namedArg.Key == triggerDefinition.ConnectionPropertyName) - { - connectionSettingName = namedArg.Value.Value as string; - } - - if (triggerDefinition.RequireAutoCompleteFalse - && triggerDefinition.AutoCompletePropertyName is not null - && namedArg.Key == triggerDefinition.AutoCompletePropertyName) - { - var autoComplete = namedArg.Value.Value as bool?; - autoCompleteEnabled = autoComplete!.Value; - } + connectionSettingName = extractedConnectionSetting; } - if (triggerDefinition.RequireAutoCompleteFalse && autoCompleteEnabled) + if (triggerDefinition.AutoComplete is AutoCompletePolicy.MustBeFalse autoCompletePolicy + && IsAutoCompleteEnabled(pAttr, autoCompletePolicy)) { - diagnostics.Add(CreateDiagnostic(DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, method, method.Name)); + diagnostics.Add(CreateDiagnostic( + DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, + method, + method.Name, + autoCompletePolicy.PropertyName, + knownTypes.TriggerAttribute.Name)); } } } @@ -371,6 +364,71 @@ static bool MatchesShape(List actualParameterRoles, TriggerShape return roleIndex == shape.OrderedParameters.Count; } + static bool TryExtractAddress(AttributeData triggerAttribute, AddressExtractionPolicy policy, [NotNullWhen(true)] out string? address) + { + switch (policy) + { + case AddressExtractionPolicy.ConstructorArgument(var index) + when triggerAttribute.ConstructorArguments.Length > index: + address = triggerAttribute.ConstructorArguments[index].Value as string; + return address is not null; + + case AddressExtractionPolicy.ConstructorArgument: + address = null; + return false; + + case AddressExtractionPolicy.NamedProperty(var propertyName): + return TryGetNamedArgumentString(triggerAttribute, propertyName, out address); + + default: + throw new InvalidOperationException($"Unsupported address extraction policy: {policy.GetType().Name}."); + } + } + + static bool TryExtractConnectionSetting(AttributeData triggerAttribute, ConnectionSettingPolicy policy, [NotNullWhen(true)] out string? connectionSetting) + { + switch (policy) + { + case ConnectionSettingPolicy.NamedProperty(var propertyName): + return TryGetNamedArgumentString(triggerAttribute, propertyName, out connectionSetting); + + case ConnectionSettingPolicy.None: + connectionSetting = null; + return false; + + default: + throw new InvalidOperationException($"Unsupported connection setting policy: {policy.GetType().Name}."); + } + } + + static bool IsAutoCompleteEnabled(AttributeData triggerAttribute, AutoCompletePolicy.MustBeFalse policy) + { + foreach (var namedArg in triggerAttribute.NamedArguments) + { + if (namedArg.Key == policy.PropertyName) + { + return namedArg.Value.Value as bool? ?? true; + } + } + + return true; + } + + static bool TryGetNamedArgumentString(AttributeData triggerAttribute, string propertyName, [NotNullWhen(true)] out string? value) + { + foreach (var namedArg in triggerAttribute.NamedArguments) + { + if (namedArg.Key == propertyName) + { + value = namedArg.Value.Value as string; + return value is not null; + } + } + + value = null; + return false; + } + static string FormatShape(ImmutableEquatableArray orderedParameters) { var builder = new StringBuilder("["); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs index c392a7b..4d2a65c 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -1,5 +1,6 @@ namespace NServiceBus.AzureFunctions.Analyzer; +using System; using NServiceBus.Core.Analyzer; public sealed partial class FunctionEndpointGenerator @@ -13,10 +14,11 @@ public sealed partial class FunctionEndpointGenerator /// in the compilation, the pipeline bails immediately with no output. /// /// - /// Current assumptions: + /// Trigger-specific parsing behavior is configured via policy types: /// - /// Address/entity name is the first constructor argument of the trigger attribute. - /// Connection setting name can be read from a named trigger property, if is configured. + /// Address/entity name extraction via . + /// Connection setting extraction via . + /// Auto-complete validation via . /// Trigger method signatures are validated against . /// /// @@ -24,13 +26,75 @@ internal sealed record TriggerDefinition( string TriggerAttributeMetadataName, ImmutableEquatableArray AdditionalParameterTypes, string ProcessorTypeFullyQualified, - string? ConnectionPropertyName, - string? AutoCompletePropertyName, - bool RequireAutoCompleteFalse, + AddressExtractionPolicy AddressExtraction, + ConnectionSettingPolicy ConnectionSetting, + AutoCompletePolicy AutoComplete, string RegistrationMethodFullyQualified, string ProcessMethodName, TriggerShape Shape); + internal abstract record AddressExtractionPolicy + { + internal sealed record ConstructorArgument(int Index) : AddressExtractionPolicy; + internal sealed record NamedProperty(string PropertyName) : AddressExtractionPolicy; + + public static AddressExtractionPolicy FromConstructorArgument(int index) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative."); + } + + return new ConstructorArgument(index); + } + + public static AddressExtractionPolicy FromNamedProperty(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + } + + return new NamedProperty(propertyName); + } + } + + internal abstract record ConnectionSettingPolicy + { + internal sealed record None : ConnectionSettingPolicy; + internal sealed record NamedProperty(string PropertyName) : ConnectionSettingPolicy; + + public static ConnectionSettingPolicy NotConfigured { get; } = new None(); + + public static ConnectionSettingPolicy FromNamedProperty(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + } + + return new NamedProperty(propertyName); + } + } + + internal abstract record AutoCompletePolicy + { + internal sealed record NotApplicable : AutoCompletePolicy; + internal sealed record MustBeFalse(string PropertyName) : AutoCompletePolicy; + + public static AutoCompletePolicy None { get; } = new NotApplicable(); + + public static AutoCompletePolicy MustBeFalseFor(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + } + + return new MustBeFalse(propertyName); + } + } + internal readonly record struct AdditionalParameterType(string MetadataName, ParameterRole Role) : IEquatable; internal readonly record struct TriggerShape(ImmutableEquatableArray OrderedParameters, bool AllowAdditionalParameters) diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index df5f01f..a5b7f15 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -12,9 +12,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - ConnectionPropertyName: "ConnSetting", - AutoCompletePropertyName: null, - RequireAutoCompleteFalse: false, + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorArgument(0), + ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), + AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 9d222ac..2ad3a22 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -16,9 +16,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - ConnectionPropertyName: "ConnSetting", - AutoCompletePropertyName: null, - RequireAutoCompleteFalse: false, + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorArgument(0), + ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), + AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", ProcessMethodName: "Process", Shape: FunctionEndpointGenerator.TriggerShape.Required( From 04ed768317d854bdd6677961a12cc1acc3cf5328 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Mon, 6 Apr 2026 15:03:00 -0500 Subject: [PATCH 12/14] Refine policy-based trigger parsing and safer address extraction --- ...nctionEndpointGenerator.AzureServiceBus.cs | 2 +- .../FunctionEndpointGenerator.Parser.cs | 32 +++++++++++++++++++ ...tionEndpointGenerator.TriggerDefinition.cs | 11 +++++++ .../FunctionEndpointGeneratorTests.cs | 25 +++++++++++++++ .../LenientNoMessageActionsGenerator.cs | 2 +- .../NoMessageActionsGenerator.cs | 2 +- 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index 9df25b5..33af3e0 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -13,7 +13,7 @@ public sealed partial class FunctionEndpointGenerator new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) }.ToImmutableEquatableArray(), ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", - AddressExtraction: AddressExtractionPolicy.FromConstructorArgument(0), + AddressExtraction: AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), ConnectionSetting: ConnectionSettingPolicy.FromNamedProperty("Connection"), AutoComplete: AutoCompletePolicy.MustBeFalseFor("AutoCompleteMessages"), RegistrationMethodFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register", diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index 717b46d..71b33cf 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -380,6 +380,9 @@ static bool TryExtractAddress(AttributeData triggerAttribute, AddressExtractionP case AddressExtractionPolicy.NamedProperty(var propertyName): return TryGetNamedArgumentString(triggerAttribute, propertyName, out address); + case AddressExtractionPolicy.ConstructorParameterNamed(var parameterName): + return TryGetConstructorArgumentStringByParameterName(triggerAttribute, parameterName, out address); + default: throw new InvalidOperationException($"Unsupported address extraction policy: {policy.GetType().Name}."); } @@ -429,6 +432,35 @@ static bool TryGetNamedArgumentString(AttributeData triggerAttribute, string pro return false; } + static bool TryGetConstructorArgumentStringByParameterName( + AttributeData triggerAttribute, + string parameterName, + [NotNullWhen(true)] out string? value) + { + var constructor = triggerAttribute.AttributeConstructor; + if (constructor is null) + { + value = null; + return false; + } + + var parameters = constructor.Parameters; + var arguments = triggerAttribute.ConstructorArguments; + var argumentCount = Math.Min(parameters.Length, arguments.Length); + + for (var i = 0; i < argumentCount; i++) + { + if (parameters[i].Name == parameterName) + { + value = arguments[i].Value as string; + return value is not null; + } + } + + value = null; + return false; + } + static string FormatShape(ImmutableEquatableArray orderedParameters) { var builder = new StringBuilder("["); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs index 4d2a65c..7a0d1c5 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -36,6 +36,7 @@ internal sealed record TriggerDefinition( internal abstract record AddressExtractionPolicy { internal sealed record ConstructorArgument(int Index) : AddressExtractionPolicy; + internal sealed record ConstructorParameterNamed(string ParameterName) : AddressExtractionPolicy; internal sealed record NamedProperty(string PropertyName) : AddressExtractionPolicy; public static AddressExtractionPolicy FromConstructorArgument(int index) @@ -57,6 +58,16 @@ public static AddressExtractionPolicy FromNamedProperty(string propertyName) return new NamedProperty(propertyName); } + + public static AddressExtractionPolicy FromConstructorParameterNamed(string parameterName) + { + if (string.IsNullOrWhiteSpace(parameterName)) + { + throw new ArgumentException("Parameter name cannot be null or whitespace.", nameof(parameterName)); + } + + return new ConstructorParameterNamed(parameterName); + } } internal abstract record ConnectionSettingPolicy diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index 824cf46..75c4122 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -397,6 +397,31 @@ public static void ConfigureProcessOrder( Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify an address or entity name")); } + [Test] + public void ReportsInvalidFunctionMethodWhenServiceBusTriggerUsesTopicSubscriptionConstructor() + { + var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + namespace Demo; + + public partial class Functions + { + [NServiceBusFunction] + [Function("ProcessOrder")] + public partial Task Run( + [ServiceBusTrigger("sales-topic", "sales-subscription", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions, + FunctionContext context, + CancellationToken cancellationToken); + + public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify an address or entity name")); + } + [Test] public void ReportsAllProblemsInSingleDiagnostic() { diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index a5b7f15..f675ed7 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -12,7 +12,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorArgument(0), + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 2ad3a22..33ec386 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -16,7 +16,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorArgument(0), + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", From 0ce182cf2f78849677697cfa6b95d8df15cf798f Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Mon, 6 Apr 2026 15:19:19 -0500 Subject: [PATCH 13/14] Refine trigger parsing and reduce noisy diagnostics --- .../FunctionEndpointGenerator.Parser.cs | 20 ++++++++++----- .../FunctionEndpointGeneratorTests.cs | 25 +++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index 71b33cf..2900fee 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -102,6 +102,8 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo string? messageParamName = null; string? functionContextParamName = null; string? cancellationTokenParamName = null; + string? autoCompletePropertyName = null; + var autoCompleteMustBeDisabled = false; var additionalParamNames = new Dictionary(); var parameterRoles = new List(); var triggerParameterCount = 0; @@ -145,12 +147,8 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo if (triggerDefinition.AutoComplete is AutoCompletePolicy.MustBeFalse autoCompletePolicy && IsAutoCompleteEnabled(pAttr, autoCompletePolicy)) { - diagnostics.Add(CreateDiagnostic( - DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, - method, - method.Name, - autoCompletePolicy.PropertyName, - knownTypes.TriggerAttribute.Name)); + autoCompleteMustBeDisabled = true; + autoCompletePropertyName ??= autoCompletePolicy.PropertyName; } } } @@ -249,6 +247,16 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo return null; } + if (autoCompleteMustBeDisabled) + { + diagnostics.Add(CreateDiagnostic( + DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, + method, + method.Name, + autoCompletePropertyName!, + knownTypes.TriggerAttribute.Name)); + } + connectionSettingName ??= ""; var ns = containingType.ContainingNamespace.ToDisplayString(); diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index 75c4122..9db93b2 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -400,7 +400,8 @@ public static void ConfigureProcessOrder( [Test] public void ReportsInvalidFunctionMethodWhenServiceBusTriggerUsesTopicSubscriptionConstructor() { - var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(""" namespace Demo; public partial class Functions @@ -408,18 +409,26 @@ public partial class Functions [NServiceBusFunction] [Function("ProcessOrder")] public partial Task Run( - [ServiceBusTrigger("sales-topic", "sales-subscription", Connection = "AzureServiceBus")] ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, - FunctionContext context, - CancellationToken cancellationToken); + [ServiceBusTrigger("sales-topic", "sales-subscription", Connection = "AzureServiceBus", AutoCompleteMessages = true)] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions, + FunctionContext context, + CancellationToken cancellationToken); public static void ConfigureProcessOrder(EndpointConfiguration endpointConfiguration) { } - } - """); + } + """) + .SuppressCompilationErrors() + .SuppressDiagnosticErrors() + .Run(); - Assert.That(diagnostic.GetMessage(), Does.Contain("trigger attribute does not specify an address or entity name")); + var diagnostics = result.GetGeneratorDiagnostics(); + using (Assert.EnterMultipleScope()) + { + Assert.That(diagnostics, Has.Some.Matches(d => d.Id == DiagnosticIds.InvalidFunctionMethod)); + Assert.That(diagnostics, Has.None.Matches(d => d.Id == DiagnosticIds.AutoCompleteEnabled)); + } } [Test] From 4e8db75bd023fc9ed3e0f2fd6cdeead85b9bf9c6 Mon Sep 17 00:00:00 2001 From: Matt Mercurio Date: Mon, 6 Apr 2026 15:29:00 -0500 Subject: [PATCH 14/14] Minor refactor of FromNamed* policy methods --- .../FunctionEndpointGenerator.AzureServiceBus.cs | 2 +- .../FunctionEndpointGenerator.TriggerDefinition.cs | 2 +- src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs | 2 +- src/Tests.Analyzers/NoMessageActionsGenerator.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index 33af3e0..7e7942a 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -13,7 +13,7 @@ public sealed partial class FunctionEndpointGenerator new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) }.ToImmutableEquatableArray(), ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", - AddressExtraction: AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), + AddressExtraction: AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), ConnectionSetting: ConnectionSettingPolicy.FromNamedProperty("Connection"), AutoComplete: AutoCompletePolicy.MustBeFalseFor("AutoCompleteMessages"), RegistrationMethodFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusFunctionManifestRegistration.Register", diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs index 7a0d1c5..c9fa019 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -59,7 +59,7 @@ public static AddressExtractionPolicy FromNamedProperty(string propertyName) return new NamedProperty(propertyName); } - public static AddressExtractionPolicy FromConstructorParameterNamed(string parameterName) + public static AddressExtractionPolicy FromNamedConstructorParameter(string parameterName) { if (string.IsNullOrWhiteSpace(parameterName)) { diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index f675ed7..5412f1a 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -12,7 +12,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 33ec386..6bebeff 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -16,7 +16,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", AdditionalParameterTypes: ImmutableEquatableArray.Empty, ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromConstructorParameterNamed("queueName"), + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register",