Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ public class CallbackService
| XPC4004 | Error | Custom API handler method not found on the service type (code fix creates it) |
| XPC4005 | Warning | Handler signature doesn't match the declared parameters, generated types don't exist yet |
| XPC4006 | Error | Handler signature doesn't match, generated types exist (code fix corrects it) |
| XPC3007 | Info | A Custom API declares request/response parameters but uses the action-based overload; prefer the typed `RegisterAPI<TService>(name, nameof(TService.Method))` overload that generates DTOs (code fix converts the lambda to `nameof`) |
| XPC3001 | Warning | Prefer `nameof(TService.Method)` over a string literal for the handler argument |

### Dependency Injection
Expand Down Expand Up @@ -491,6 +492,10 @@ Version numbers are managed through CHANGELOG.md files:

`Directory.Build.targets` derives each project's `<Version>` from the latest `### v...` entry in the relevant CHANGELOG.md at build time — no script or CI step required. Core and the source generator share `XrmPluginCore/CHANGELOG.md`; Abstractions overrides `ChangelogPath` in its .csproj to use its own changelog. An explicit `-p:Version=...` still overrides the derived value.

The pending (not-yet-released) entry is marked `### vX.Y.Z - Unreleased` instead of a date. Only the version number is parsed (the ` - Unreleased`/date suffix is ignored), so this doesn't affect the derived `<Version>`. Add new changes to that existing `- Unreleased` entry rather than creating a new heading; when the version is released, replace `Unreleased` with the release date.

Analyzer rules are tracked separately in `XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md`; new rules go there and are moved to `AnalyzerReleases.Shipped.md` (under a `## Release X.Y.Z` heading) when that version is released.

## Source Generator Development

When developing or debugging the source generator, use the following workflow to test changes against a local project:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,141 @@ public async Task Should_Report_XPC3001_For_String_Literal_Handler()
diagnostic.Properties["MethodName"].Should().Be("Handle");
}

[Fact]
public async Task Should_Report_XPC3007_For_Action_Overload_With_Parameters()
{
const string registration = """
RegisterAPI<CallbackService>(nameof(SomeApi), s => s.Handle())
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
""";
var source = WrapPlugin(registration, serviceBody: "public void Handle() { }");

var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer());

var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3007").Subject;
diagnostic.Severity.Should().Be(DiagnosticSeverity.Info);
diagnostic.Properties["ServiceType"].Should().Be("CallbackService");
diagnostic.Properties["MethodName"].Should().Be("Handle");
}

[Fact]
public async Task Should_Report_XPC3007_When_Only_Response_Declared()
{
const string registration = """
RegisterAPI<CallbackService>(nameof(SomeApi), s => s.Handle())
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
""";
var source = WrapPlugin(registration, serviceBody: "public void Handle() { }");

var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer());

diagnostics.Should().ContainSingle(d => d.Id == "XPC3007");
}

[Fact]
public async Task Should_Not_Report_XPC3007_When_No_Parameters_Declared()
{
const string registration = """
RegisterAPI<CallbackService>(nameof(SomeApi), s => s.Handle());
""";
var source = WrapPlugin(registration, serviceBody: "public void Handle() { }");

var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer());

diagnostics.Should().NotContain(d => d.Id == "XPC3007");
}

[Fact]
public async Task Should_Not_Report_XPC3007_For_Typed_Handler_Overload()
{
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);")
+ GeneratedTypes;

var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer());

diagnostics.Should().NotContain(d => d.Id == "XPC3007");
}

[Fact]
public async Task Should_Report_XPC3007_Without_MethodName_When_Call_Is_Not_On_Lambda_Parameter()
{
// The lambda calls a method on a different receiver, not the service parameter, so no
// nameof(CallbackService.X) can be safely suggested: report the diagnostic but offer no fix.
const string source = """
using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;

namespace TestNamespace
{
public static class Other { public static void Handle() { } }

public class SomeApi : Plugin
{
public SomeApi()
{
RegisterAPI<CallbackService>(nameof(SomeApi), s => Other.Handle())
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services.AddScoped<CallbackService>();
}

public class CallbackService
{
public void Handle() { }
}
}
""";

var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer());

var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3007").Subject;
diagnostic.Properties.ContainsKey("MethodName").Should().BeFalse();
}

[Fact]
public async Task Should_Fix_Action_Overload_With_Qualified_Service_Type()
{
// A namespace-qualified generic argument must produce valid nameof syntax (not a single
// malformed identifier).
const string registration = """
RegisterAPI<TestNamespace.CallbackService>(nameof(SomeApi), s => s.Handle())
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
""";
var source = WrapPlugin(registration, serviceBody: "public void Handle() { }");

var fixedSource = await ApplyCodeFixAsync(
source,
new PreferTypedCustomApiHandlerAnalyzer(),
new PreferTypedCustomApiHandlerCodeFixProvider(),
DiagnosticDescriptors.PreferTypedCustomApiHandler.Id);

fixedSource.Should().Contain("nameof(TestNamespace.CallbackService.Handle)");
}

[Fact]
public async Task Should_Fix_Action_Overload_To_Nameof()
{
const string registration = """
RegisterAPI<CallbackService>(nameof(SomeApi), s => s.Handle())
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
""";
var source = WrapPlugin(registration, serviceBody: "public void Handle() { }");

var fixedSource = await ApplyCodeFixAsync(
source,
new PreferTypedCustomApiHandlerAnalyzer(),
new PreferTypedCustomApiHandlerCodeFixProvider(),
DiagnosticDescriptors.PreferTypedCustomApiHandler.Id);

fixedSource.Should().Contain("nameof(CallbackService.Handle)");
fixedSource.Should().NotContain("s => s.Handle()");
}

[Fact]
public async Task Should_Fix_Missing_Handler_Method()
{
Expand Down
11 changes: 11 additions & 0 deletions XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## Release 1.4.1

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
XPC3006 | XrmPluginCore.SourceGenerator | Warning | XPC3006 Custom API name must be a compile-time constant
XPC4004 | XrmPluginCore.SourceGenerator | Error | XPC4004 Custom API handler method not found
XPC4005 | XrmPluginCore.SourceGenerator | Warning | XPC4005 Custom API handler signature mismatch (generated types don't exist)
XPC4006 | XrmPluginCore.SourceGenerator | Error | XPC4006 Custom API handler signature mismatch (generated types exist)

## Release 1.2.7

### New Rules
Expand Down
5 changes: 1 addition & 4 deletions XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
XPC3006 | XrmPluginCore.SourceGenerator | Warning | XPC3006 Custom API name must be a compile-time constant
XPC4004 | XrmPluginCore.SourceGenerator | Error | XPC4004 Custom API handler method not found
XPC4005 | XrmPluginCore.SourceGenerator | Warning | XPC4005 Custom API handler signature mismatch (generated types don't exist)
XPC4006 | XrmPluginCore.SourceGenerator | Error | XPC4006 Custom API handler signature mismatch (generated types exist)
XPC3007 | XrmPluginCore.SourceGenerator | Info | XPC3007 Prefer the type-safe Custom API handler overload when request/response parameters are declared

### Removed Rules

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using XrmPluginCore.SourceGenerator.Helpers;

namespace XrmPluginCore.SourceGenerator.Analyzers;

/// <summary>
/// Suggests migrating an action-based <c>RegisterAPI&lt;TService&gt;(name, Action&lt;TService&gt;)</c> call that
/// declares <c>AddRequestParameter</c>/<c>AddResponseProperty</c> to the type-safe
/// <c>RegisterAPI&lt;TService&gt;(name, nameof(TService.Handler))</c> overload, which generates strongly-typed
/// request/response DTOs (mirroring the type-safe image support for plugin steps).
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class PreferTypedCustomApiHandlerAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DiagnosticDescriptors.PreferTypedCustomApiHandler);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}

private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;

// Only the generic action overload is a candidate: the typed handler-name overload already
// generates DTOs, and the non-generic overloads have no TService to point nameof(...) at.
if (!RegisterApiHelper.IsRegisterApiCall(invocation, out var genericName) ||
!RegisterApiHelper.IsActionOverload(invocation, context.SemanticModel))
{
return;
}

if (genericName.TypeArgumentList.Arguments.Count < 1)
{
return;
}

// Only suggest the typed overload when there is actually a request/response shape to generate.
var (hasRequest, hasResponse) = RegisterApiHelper.CheckForParameters(invocation);
if (!hasRequest && !hasResponse)
{
return;
}

var actionArgument = RegisterApiHelper.GetActionArgument(invocation, context.SemanticModel);
if (actionArgument == null)
{
return;
}

var serviceType = genericName.TypeArgumentList.Arguments[0].ToString();

// Pass the handler method name to the code fix when it can be extracted from the lambda body;
// when it can't (e.g. a statement block), the diagnostic still reports but no automatic fix is offered.
var properties = ImmutableDictionary.CreateBuilder<string, string>();
properties.Add(Constants.PropertyServiceType, serviceType);

var methodName = RegisterApiHelper.GetActionMethodName(actionArgument);
if (methodName != null)
{
properties.Add(Constants.PropertyMethodName, methodName);
}

context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.PreferTypedCustomApiHandler,
actionArgument.GetLocation(),
properties.ToImmutable(),
serviceType));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using XrmPluginCore.SourceGenerator.Helpers;

namespace XrmPluginCore.SourceGenerator.CodeFixes;

/// <summary>
/// Converts an action-based Custom API handler (e.g. <c>s =&gt; s.Handle()</c>) to the type-safe
/// <c>nameof(TService.Handle)</c> form so the source generator emits strongly-typed request/response DTOs.
/// The follow-up signature adjustment is then guided by XPC4005/XPC4006 and their code fix.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferTypedCustomApiHandlerCodeFixProvider)), Shared]
public class PreferTypedCustomApiHandlerCodeFixProvider : CodeFixProvider
{
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(DiagnosticDescriptors.PreferTypedCustomApiHandler.Id);

public sealed override FixAllProvider GetFixAllProvider() =>
WellKnownFixAllProviders.BatchFixer;

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root == null)
{
return;
}

var diagnostic = context.Diagnostics.First();

// Only offer the fix when the analyzer could extract the handler method name from the lambda.
if (!diagnostic.Properties.TryGetValue(Constants.PropertyServiceType, out var serviceType) ||
!diagnostic.Properties.TryGetValue(Constants.PropertyMethodName, out var methodName) ||
string.IsNullOrEmpty(methodName))
{
return;
}

var node = root.FindNode(diagnostic.Location.SourceSpan);
var actionArgument = node as ExpressionSyntax
?? node.DescendantNodesAndSelf().OfType<LambdaExpressionSyntax>().FirstOrDefault();
if (actionArgument == null)
{
return;
}

context.RegisterCodeFix(
CodeAction.Create(
title: $"Use nameof({serviceType}.{methodName})",
createChangedDocument: c => ConvertToNameofAsync(context.Document, actionArgument, serviceType, methodName, c),
equivalenceKey: nameof(PreferTypedCustomApiHandlerCodeFixProvider)),
diagnostic);
}

private static async Task<Document> ConvertToNameofAsync(
Document document,
ExpressionSyntax actionArgument,
string serviceType,
string methodName,
CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root == null)
{
return document;
}

var nameofExpression = SyntaxFactoryHelper.CreateNameofExpression(serviceType, methodName)
.WithTriviaFrom(actionArgument);
Comment thread
mkholt marked this conversation as resolved.

var newRoot = root.ReplaceNode(actionArgument, nameofExpression);
return document.WithSyntaxRoot(newRoot);
}
}
10 changes: 10 additions & 0 deletions XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ public static class DiagnosticDescriptors
description: "The typed RegisterAPI<TService>(name, handlerMethodName) overload requires a constant name so the generated classes can be named after the API. A non-constant name produces no generated ActionWrapper and fails at runtime.",
helpLinkUri: $"{HelpLinkBaseUri}/XPC3006.md");

public static readonly DiagnosticDescriptor PreferTypedCustomApiHandler = new(
id: "XPC3007",
title: "Prefer the type-safe Custom API handler overload",
messageFormat: "This Custom API declares request/response parameters; use the typed 'RegisterAPI<{0}>(name, nameof(...))' overload to generate strongly-typed request/response DTOs instead of the action-based overload",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: "When a Custom API declares AddRequestParameter/AddResponseProperty, the typed RegisterAPI<TService>(name, handlerMethodName) overload generates strongly-typed {ApiName}Request/{ApiName}Response classes and marshals InputParameters/OutputParameters for you. The action-based overload leaves you to read and write those dictionaries by hand.",
helpLinkUri: $"{HelpLinkBaseUri}/XPC3007.md");

public static readonly DiagnosticDescriptor CustomApiHandlerMethodNotFound = new(
id: "XPC4004",
title: "Custom API handler method not found",
Expand Down
Loading
Loading