diff --git a/packages/http-client-csharp/.tspd/docs/customization.md b/packages/http-client-csharp/.tspd/docs/customization.md
index 87286f97a19..c2ff40663e2 100644
--- a/packages/http-client-csharp/.tspd/docs/customization.md
+++ b/packages/http-client-csharp/.tspd/docs/customization.md
@@ -1273,6 +1273,88 @@ namespace Azure.Service.Operations
+## Customize a generated client method's signature
+
+Declare a `partial` method (without a body) on the generated client class with the same name and parameter types as a generated protocol or convenience method. The generator emits a matching partial implementation, taking the modifiers and parameter names from your declaration while keeping its generated body.
+
+This applies specifically to **client methods** (protocol and convenience methods on the generated `*Client` class). Other generated members (model constructors, model serialization methods, model factories, etc.) should be customized using `[CodeGenSuppress]` + a hand-written replacement, or one of the other techniques in this document.
+
+Use this when you want to keep the generated body but tweak the surface — typically:
+
+- Tighten the access modifier (`public` → `internal`).
+- Add `virtual` / `override` / `new` modifiers.
+- Rename a parameter to something more idiomatic.
+
+This is preferred over `[CodeGenSuppress]` + a hand-written replacement when the only thing you want to change is the signature, because you stay in lock-step with the generated body — future regenerations automatically pick up changes (new optional parameters, body changes from spec edits, etc.).
+
+### What is supported
+
+- **Modifier changes** (access modifier, `virtual`, `override`, `new`, `unsafe`).
+- **Parameter renames.** The generator clones each parameter with the customer-chosen name while preserving all internal metadata (parameter location, wire info, spread source, validation, …) so the generated body keeps compiling.
+- Both protocol method overloads and convenience method overloads (sync and async) on the generated client. Each overload must be customized independently.
+
+### What is NOT supported
+
+- **Renaming the method itself.** Matching is by `(method name, ordered parameter type list)`. To rename, use `[CodeGenSuppress]` + a hand-written method that delegates to the underlying request/pipeline machinery.
+- **Changing parameter types.** The parameter type list must match the generated signature exactly (matched by type name). Replacing a parameter type with a different type — even an implicitly convertible one — will simply fail to match and the partial decl will be ignored. To project a different type, use `[CodeGenSuppress]` + a hand-written wrapper that converts and forwards.
+- **Adding/removing/reordering parameters.** The parameter type list must match the generated signature exactly. To restructure, use `[CodeGenSuppress]`.
+- **Default values on the partial implementation.** C# requires partial method implementations to have all parameters required, so any default values on your partial declaration are stripped on the generated impl. Callers still see the defaults from your declaration in custom code.
+- **Non-client members** (models, serialization methods, model factories). Use `[CodeGenSuppress]` for these.
+
+
+
+**Generated code before (Generated/TestClient.cs):**
+
+```C#
+namespace Azure.Service
+{
+ public partial class TestClient
+ {
+ public virtual ClientResult HelloAgain(BinaryContent p1, RequestOptions options = null)
+ {
+ Argument.AssertNotNull(p1, nameof(p1));
+ using PipelineMessage message = CreateRequest(p1, options);
+ return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options));
+ }
+ }
+}
+```
+
+**Add customized client (TestClient.cs):**
+
+```C#
+namespace Azure.Service
+{
+ public partial class TestClient
+ {
+ // Renames `p1` to `content` and changes accessibility from public to internal.
+ internal partial ClientResult HelloAgain(BinaryContent content, RequestOptions options);
+ }
+}
+```
+
+**Generated code after (Generated/TestClient.cs):**
+
+```diff
+namespace Azure.Service
+{
+ public partial class TestClient
+ {
+- public virtual ClientResult HelloAgain(BinaryContent p1, RequestOptions options = null)
++ internal partial ClientResult HelloAgain(BinaryContent content, RequestOptions options)
+ {
+- Argument.AssertNotNull(p1, nameof(p1));
+- using PipelineMessage message = CreateRequest(p1, options);
++ Argument.AssertNotNull(content, nameof(content));
++ using PipelineMessage message = CreateRequest(content, options);
+ return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options));
+ }
+ }
+}
+```
+
+
+
## Replace any generated member
Works for model and client properties, methods, constructors etc.
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs
index 6e63a330e24..c85805768e9 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ScmMethodProviderCollection.cs
@@ -110,31 +110,69 @@ protected virtual IReadOnlyList BuildMethods()
private ScmMethodProvider BuildConvenienceMethod(MethodProvider protocolMethod, bool isAsync)
{
- if (EnclosingType is not ClientProvider)
+ if (EnclosingType is not ClientProvider client)
{
throw new InvalidOperationException("Protocol methods can only be built for client types.");
}
- var methodSignature = new MethodSignature(
- isAsync ? ServiceMethod.Name + "Async" : ServiceMethod.Name,
- DocHelpers.GetFormattableDescription(ServiceMethod.Operation.Summary, ServiceMethod.Operation.Doc) ?? FormattableStringHelpers.FromString(ServiceMethod.Operation.Name),
- protocolMethod.Signature.Modifiers,
- GetResponseType(ServiceMethod.Operation.Responses, true, isAsync, out var responseBodyType),
- null,
- [.. ConvenienceMethodParameters, ScmKnownParameters.CancellationToken]);
+ var methodName = isAsync ? ServiceMethod.Name + "Async" : ServiceMethod.Name;
+ ParameterProvider[] signatureParameters = [.. ConvenienceMethodParameters, ScmKnownParameters.CancellationToken];
+
+ // Detect a partial method declaration in the client's custom code matching this convenience method.
+ MethodSignature? customSignature = null;
+ PartialMethodCustomization.TryFindCustomSignature(client, methodName, signatureParameters, out customSignature);
+
+ // Parameters used to construct the method body. When customizing, we clone the generator's
+ // parameter providers with the customer's names but keep all generator metadata so that the
+ // body construction (param conversions, request invocation, etc.) still works.
+ IReadOnlyList convenienceBodyParameters;
+
+ MethodSignature methodSignature;
+
+ if (customSignature != null)
+ {
+ var renamedSignatureParameters = PartialMethodCustomization.RenameAndCloneParameters(
+ signatureParameters,
+ customSignature.Parameters,
+ removeDefaults: true);
+
+ // The generator-controlled body params are the leading parameters (everything except the trailing CancellationToken).
+ var bodyParams = new ParameterProvider[ConvenienceMethodParameters.Count];
+ for (int i = 0; i < ConvenienceMethodParameters.Count; i++)
+ {
+ bodyParams[i] = renamedSignatureParameters[i];
+ }
+ convenienceBodyParameters = bodyParams;
+
+ methodSignature = PartialMethodCustomization.BuildPartialSignature(customSignature, renamedSignatureParameters);
+ }
+ else
+ {
+ convenienceBodyParameters = ConvenienceMethodParameters;
+ methodSignature = new MethodSignature(
+ methodName,
+ DocHelpers.GetFormattableDescription(ServiceMethod.Operation.Summary, ServiceMethod.Operation.Doc) ?? FormattableStringHelpers.FromString(ServiceMethod.Operation.Name),
+ protocolMethod.Signature.Modifiers,
+ GetResponseType(ServiceMethod.Operation.Responses, true, isAsync, out _),
+ null,
+ signatureParameters);
+ }
+
+ // Recompute the response body type so we can branch the body accordingly.
+ GetResponseType(ServiceMethod.Operation.Responses, true, isAsync, out var responseBodyType);
MethodBodyStatement[] methodBody;
TypeProvider? collection = null;
if (_pagingServiceMethod != null)
{
collection = ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.CreateClientCollectionResultDefinition(Client, _pagingServiceMethod, responseBodyType, isAsync);
- methodBody = [.. GetPagingMethodBody(collection, ConvenienceMethodParameters, true)];
+ methodBody = [.. GetPagingMethodBody(collection, convenienceBodyParameters, true)];
}
else if (responseBodyType is null)
{
methodBody =
[
- .. GetStackVariablesForProtocolParamConversion(ConvenienceMethodParameters, out var declarations),
+ .. GetStackVariablesForProtocolParamConversion(convenienceBodyParameters, out var declarations),
Return(This.Invoke(protocolMethod.Signature, [.. GetProtocolMethodArguments(declarations)], isAsync))
];
}
@@ -142,7 +180,7 @@ .. GetStackVariablesForProtocolParamConversion(ConvenienceMethodParameters, out
{
methodBody =
[
- .. GetStackVariablesForProtocolParamConversion(ConvenienceMethodParameters, out var paramDeclarations),
+ .. GetStackVariablesForProtocolParamConversion(convenienceBodyParameters, out var paramDeclarations),
Declare("result", This.Invoke(protocolMethod.Signature, [.. GetProtocolMethodArguments(paramDeclarations)], isAsync).ToApi(), out ClientResponseApi result),
.. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, out var resultDeclarations),
IsConvertibleFromBinaryData(responseBodyType)
@@ -933,21 +971,50 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod
}
ParameterProvider[] parameters = [.. requiredParameters, .. optionalParameters, requestOptionsParameter];
+ var methodName = isAsync ? ServiceMethod.Name + "Async" : ServiceMethod.Name;
+
+ // Detect a partial method declaration in the client's custom code matching this protocol method.
+ // When found, we use the customized signature (modifiers, name, parameter names) and emit the
+ // generated body using the customized parameter references.
+ MethodSignature? customSignature = null;
+ PartialMethodCustomization.TryFindCustomSignature(client, methodName, parameters, out customSignature);
+
+ MethodSignature methodSignature;
+ ParameterProvider[] bodyParameters;
+
+ if (customSignature != null)
+ {
+ // Partial methods cannot have optional parameters in the implementation.
+ var requiredCustomParameters = PartialMethodCustomization.RenameAndCloneParameters(
+ customSignature.Parameters,
+ customSignature.Parameters,
+ removeDefaults: true).ToArray();
+
+ methodSignature = PartialMethodCustomization.BuildPartialSignature(customSignature, requiredCustomParameters);
- var methodSignature = new MethodSignature(
- isAsync ? ServiceMethod.Name + "Async" : ServiceMethod.Name,
- DocHelpers.GetFormattableDescription(ServiceMethod.Operation.Summary, ServiceMethod.Operation.Doc) ?? FormattableStringHelpers.FromString(ServiceMethod.Operation.Name),
- methodModifiers,
- GetResponseType(ServiceMethod.Operation.Responses, false, isAsync, out _),
- $"The response returned from the service.",
- parameters);
+ bodyParameters = requiredCustomParameters;
+ // Re-resolve the request options parameter from the customized parameter list so the
+ // generated body references the user-named options parameter (typically the last param).
+ requestOptionsParameter = requiredCustomParameters[requiredCustomParameters.Length - 1];
+ }
+ else
+ {
+ methodSignature = new MethodSignature(
+ methodName,
+ DocHelpers.GetFormattableDescription(ServiceMethod.Operation.Summary, ServiceMethod.Operation.Doc) ?? FormattableStringHelpers.FromString(ServiceMethod.Operation.Name),
+ methodModifiers,
+ GetResponseType(ServiceMethod.Operation.Responses, false, isAsync, out _),
+ $"The response returned from the service.",
+ parameters);
+ bodyParameters = parameters;
+ }
TypeProvider? collection = null;
MethodBodyStatement[] methodBody;
if (_pagingServiceMethod != null)
{
collection = ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.CreateClientCollectionResultDefinition(Client, _pagingServiceMethod, null, isAsync);
- methodBody = [.. GetPagingMethodBody(collection, parameters, false)];
+ methodBody = [.. GetPagingMethodBody(collection, bodyParameters, false)];
}
else
{
@@ -956,7 +1023,7 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod
[
UsingDeclare("message", ScmCodeModelGenerator.Instance.TypeFactory.HttpMessageApi.HttpMessageType,
This.Invoke(createRequestMethod.Signature,
- [.. parameters.Select(p => (ValueExpression)p)]), out var message),
+ [.. bodyParameters.Select(p => (ValueExpression)p)]), out var message),
Return(ScmCodeModelGenerator.Instance.TypeFactory.ClientResponseApi.ToExpression().FromResponse(client
.PipelineProperty.Invoke(processMessageName, [message, requestOptionsParameter], isAsync, true, extensionType: _clientPipelineExtensionsDefinition.Type)))
];
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderCustomizationTests.cs
index 0b8620ee7ab..b7c64f4be5e 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderCustomizationTests.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderCustomizationTests.cs
@@ -334,5 +334,135 @@ public async Task CanRemoveCachingField()
var cachingField = fields.SingleOrDefault(f => f.Name == "_cachedDog");
Assert.IsNull(cachingField);
}
+
+ // Validates that a generated protocol method can be customized via a partial method declaration in custom code.
+ [Test]
+ public async Task CanCustomizeMethodSignature()
+ {
+ var inputOperation = InputFactory.Operation("HelloAgain", parameters:
+ [
+ InputFactory.BodyParameter("p1", InputFactory.Array(InputPrimitiveType.String))
+ ]);
+ var inputServiceMethod = InputFactory.BasicServiceMethod("test", inputOperation);
+ var inputClient = InputFactory.Client("TestClient", methods: [inputServiceMethod]);
+ var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
+ clients: () => [inputClient],
+ compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
+
+ var clientProvider = mockGenerator.Object.OutputLibrary.TypeProviders.SingleOrDefault(t => t is ClientProvider);
+ Assert.IsNotNull(clientProvider);
+
+ // Find the protocol method that should now be partial.
+ var partialMethod = clientProvider!.Methods.FirstOrDefault(m =>
+ m.Signature.Name == "HelloAgain"
+ && m.IsPartialMethod
+ && m.Signature.Parameters.Any(p => p.Type.Name == "BinaryContent"));
+ Assert.IsNotNull(partialMethod, "HelloAgain protocol method should be generated as partial");
+ Assert.IsTrue(partialMethod!.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial));
+
+ // Custom signature changes should be applied (parameter renamed to "content").
+ Assert.AreEqual(2, partialMethod.Signature.Parameters.Count);
+ Assert.AreEqual("content", partialMethod.Signature.Parameters[0].Name);
+ Assert.AreEqual("options", partialMethod.Signature.Parameters[1].Name);
+
+ // All parameters in the partial implementation must be required (no default values).
+ Assert.IsTrue(partialMethod.Signature.Parameters.All(p => p.DefaultValue == null));
+
+ // The original generated (non-partial) HelloAgain protocol method should not also be present.
+ var nonPartialDuplicates = clientProvider.Methods.Where(m =>
+ m.Signature.Name == "HelloAgain"
+ && !m.IsPartialMethod
+ && m.Signature.Parameters.Any(p => p.Type.Name == "BinaryContent")).ToList();
+ Assert.AreEqual(0, nonPartialDuplicates.Count);
+ }
+
+ [Test]
+ public async Task CanCustomizeMethodModifierOnly()
+ {
+ // Verifies that a partial method declaration that changes only the access modifier
+ // (without renaming any parameters) still produces a partial implementation with
+ // the customer's modifier and the generator's parameter names.
+ var inputOperation = InputFactory.Operation("HelloAgain", parameters:
+ [
+ InputFactory.BodyParameter("p1", InputFactory.Array(InputPrimitiveType.String))
+ ]);
+ var inputServiceMethod = InputFactory.BasicServiceMethod("test", inputOperation);
+ var inputClient = InputFactory.Client("TestClient", methods: [inputServiceMethod]);
+ var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
+ clients: () => [inputClient],
+ compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
+
+ var clientProvider = mockGenerator.Object.OutputLibrary.TypeProviders.SingleOrDefault(t => t is ClientProvider);
+ Assert.IsNotNull(clientProvider);
+
+ var partialMethod = clientProvider!.Methods.FirstOrDefault(m =>
+ m.Signature.Name == "HelloAgain"
+ && m.IsPartialMethod
+ && m.Signature.Parameters.Any(p => p.Type.Name == "BinaryContent"));
+ Assert.IsNotNull(partialMethod, "HelloAgain protocol method should be generated as partial");
+ Assert.IsTrue(partialMethod!.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial));
+
+ // Modifier comes from the partial declaration -> should be internal.
+ Assert.IsTrue(partialMethod.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Internal));
+ Assert.IsFalse(partialMethod.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Public));
+
+ // Parameter names are unchanged (generator-chosen).
+ Assert.AreEqual(2, partialMethod.Signature.Parameters.Count);
+ Assert.AreEqual("p1", partialMethod.Signature.Parameters[0].Name);
+ Assert.AreEqual("options", partialMethod.Signature.Parameters[1].Name);
+
+ // Defaults stripped on the implementation.
+ Assert.IsTrue(partialMethod.Signature.Parameters.All(p => p.DefaultValue == null));
+ }
+
+ [Test]
+ public async Task CanCustomizeConvenienceMethodSignature()
+ {
+ // Verifies the matching/cloning logic in BuildConvenienceMethod: a partial method
+ // declaration on the convenience overload renames parameters and the generator
+ // emits a partial implementation that references the customer-chosen names.
+ List methodParameters =
+ [
+ InputFactory.MethodParameter("p1", InputFactory.Array(InputPrimitiveType.String))
+ ];
+ List operationParameters =
+ [
+ InputFactory.BodyParameter("p1", InputFactory.Array(InputPrimitiveType.String))
+ ];
+ var inputOperation = InputFactory.Operation("HelloAgain", parameters: operationParameters);
+ var inputServiceMethod = InputFactory.BasicServiceMethod("HelloAgain", inputOperation, parameters: methodParameters);
+ var inputClient = InputFactory.Client("TestClient", methods: [inputServiceMethod]);
+ var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
+ clients: () => [inputClient],
+ compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
+
+ var clientProvider = mockGenerator.Object.OutputLibrary.TypeProviders.SingleOrDefault(t => t is ClientProvider);
+ Assert.IsNotNull(clientProvider);
+
+ // Find the convenience overload (the one whose first parameter is IEnumerable,
+ // not BinaryContent).
+ var convenienceMethod = clientProvider!.Methods.FirstOrDefault(m =>
+ m.Signature.Name == "HelloAgain"
+ && m.IsPartialMethod
+ && m.Signature.Parameters.Any(p => p.Type.IsList));
+ Assert.IsNotNull(convenienceMethod, "HelloAgain convenience method should be generated as partial");
+ Assert.IsTrue(convenienceMethod!.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial));
+
+ // Parameters take their names from the partial declaration.
+ Assert.AreEqual(2, convenienceMethod.Signature.Parameters.Count);
+ Assert.AreEqual("body", convenienceMethod.Signature.Parameters[0].Name);
+ Assert.AreEqual("ct", convenienceMethod.Signature.Parameters[1].Name);
+
+ // The cloned parameters preserve generator-side metadata: the body parameter
+ // still reports its original Location so the body construction works.
+ Assert.AreEqual("CancellationToken", convenienceMethod.Signature.Parameters[1].Type.Name);
+
+ // No duplicate non-partial convenience method.
+ var nonPartialDuplicates = clientProvider.Methods.Where(m =>
+ m.Signature.Name == "HelloAgain"
+ && !m.IsPartialMethod
+ && m.Signature.Parameters.Any(p => p.Type.IsList)).ToList();
+ Assert.AreEqual(0, nonPartialDuplicates.Count);
+ }
}
}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeConvenienceMethodSignature/CanCustomizeConvenienceMethodSignature.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeConvenienceMethodSignature/CanCustomizeConvenienceMethodSignature.cs
new file mode 100644
index 00000000000..4d0b4b83437
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeConvenienceMethodSignature/CanCustomizeConvenienceMethodSignature.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.ClientModel;
+
+namespace Sample
+{
+ public partial class TestClient
+ {
+ // Customizes the convenience method's signature: renames `p1` to `body` and
+ // `cancellationToken` to `ct`. The generator emits a partial implementation
+ // using these names while keeping the generated body.
+ public partial ClientResult HelloAgain(IEnumerable body, CancellationToken ct);
+ }
+}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodModifierOnly/CanCustomizeMethodModifierOnly.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodModifierOnly/CanCustomizeMethodModifierOnly.cs
new file mode 100644
index 00000000000..b24ea55a64d
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodModifierOnly/CanCustomizeMethodModifierOnly.cs
@@ -0,0 +1,12 @@
+using System.ClientModel;
+using System.ClientModel.Primitives;
+
+namespace Sample
+{
+ public partial class TestClient
+ {
+ // Modifier-only customization: changes accessibility from public to internal,
+ // keeps the generator-chosen parameter names (`p1`, `options`).
+ internal partial ClientResult HelloAgain(BinaryContent p1, RequestOptions options);
+ }
+}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodSignature/CanCustomizeMethodSignature.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodSignature/CanCustomizeMethodSignature.cs
new file mode 100644
index 00000000000..1148c7d7574
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodSignature/CanCustomizeMethodSignature.cs
@@ -0,0 +1,12 @@
+using System.ClientModel;
+using System.ClientModel.Primitives;
+
+namespace Sample
+{
+ public partial class TestClient
+ {
+ // Partial method declaration that customizes the generated protocol method's
+ // signature (renames parameters). The generator emits the partial implementation.
+ public partial ClientResult HelloAgain(BinaryContent content, RequestOptions options);
+ }
+}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/MethodProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/MethodProvider.cs
index 4418cba645b..207354db638 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/MethodProvider.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/MethodProvider.cs
@@ -23,6 +23,12 @@ public class MethodProvider
public IReadOnlyList Suppressions { get; internal set; }
+ ///
+ /// Indicates whether this method is declared as a partial method.
+ /// Derived from the modifier on the signature.
+ ///
+ public bool IsPartialMethod => Signature?.Modifiers.HasFlag(MethodSignatureModifiers.Partial) ?? false;
+
// for mocking
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
protected MethodProvider()
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs
index 699f97c788c..25c53b32916 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/NamedTypeSymbolProvider.cs
@@ -263,6 +263,13 @@ protected internal override MethodProvider[] BuildMethods()
kindOptions: SymbolDisplayKindOptions.None);
AddAdditionalModifiers(methodSymbol, ref modifiers);
+
+ bool isPartialDeclaration = IsPartialMethodDeclaration(methodSymbol);
+ if (isPartialDeclaration)
+ {
+ modifiers |= MethodSignatureModifiers.Partial;
+ }
+
var explicitInterface = methodSymbol.ExplicitInterfaceImplementations.FirstOrDefault();
// For conversion operators, use the target type name as the method name to match generated code
@@ -292,6 +299,24 @@ [.. methodSymbol.Parameters.Select(p => ConvertToParameterProvider(methodSymbol,
return [.. methods];
}
+ private static bool IsPartialMethodDeclaration(IMethodSymbol methodSymbol)
+ {
+ foreach (var syntaxReference in methodSymbol.DeclaringSyntaxReferences)
+ {
+ if (syntaxReference.GetSyntax() is MethodDeclarationSyntax methodSyntax)
+ {
+ bool hasPartialModifier = methodSyntax.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
+ bool hasNoBody = methodSyntax.Body == null && methodSyntax.ExpressionBody == null;
+ if (hasPartialModifier && hasNoBody)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
protected override bool GetIsEnum() => _namedTypeSymbol.TypeKind == TypeKind.Enum;
protected override CSharpType BuildEnumUnderlyingType() => GetIsEnum() ? new CSharpType(typeof(int)) : throw new InvalidOperationException("This type is not an enum");
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs
new file mode 100644
index 00000000000..d86863450c3
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs
@@ -0,0 +1,217 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.TypeSpec.Generator.Primitives;
+
+namespace Microsoft.TypeSpec.Generator.Providers
+{
+ ///
+ /// Helpers used to honor C# partial method declarations supplied in customer code
+ /// when emitting generated method signatures and bodies.
+ ///
+ ///
+ /// A library author can declare an unimplemented partial method on a generated
+ /// to customize the method's signature (modifiers, name, parameter
+ /// names) while keeping the generator-emitted body. The generator detects the partial
+ /// declaration on and uses the helpers in this class
+ /// to:
+ ///
+ /// - find a matching custom partial signature for a generated method,
+ /// - clone the generator's s with the customer-chosen names
+ /// while preserving generator metadata (so the body keeps compiling), and
+ /// - build a final with partial applied and all
+ /// parameters required (a C# constraint on partial method implementations).
+ ///
+ /// These helpers are exposed publicly so that downstream emitters (for example the management
+ /// emitter, which wraps ClientProviders in its own public-surface providers) can reuse
+ /// the same matching and parameter-cloning behavior in their own method builders.
+ ///
+ public static class PartialMethodCustomization
+ {
+ ///
+ /// Attempts to find a customer-declared partial method on 's
+ /// matching the given method name and parameter list
+ /// (matched by parameter type names, position-sensitive).
+ ///
+ /// The generated type whose custom code view should be searched.
+ /// The generated method's name.
+ /// The generated method's parameter list (in declaration order).
+ /// When the method returns true, set to the matching partial signature from custom code.
+ /// true when a matching partial declaration was found; false otherwise.
+ public static bool TryFindCustomSignature(
+ TypeProvider enclosingType,
+ string methodName,
+ IReadOnlyList parameters,
+ out MethodSignature? customSignature)
+ {
+ customSignature = null;
+
+ if (enclosingType is null)
+ {
+ return false;
+ }
+
+ var customMethods = enclosingType.CustomCodeView?.Methods;
+ if (customMethods == null || customMethods.Count == 0)
+ {
+ return false;
+ }
+
+ MethodSignature? firstMatch = null;
+ foreach (var customMethod in customMethods)
+ {
+ if (!customMethod.IsPartialMethod)
+ {
+ continue;
+ }
+
+ var candidate = customMethod.Signature;
+ if (candidate.Name != methodName || candidate.Parameters.Count != parameters.Count)
+ {
+ continue;
+ }
+
+ bool match = true;
+ for (int i = 0; i < parameters.Count; i++)
+ {
+ // The customer's CSharpType may not carry a namespace (when referencing a
+ // not-yet-generated type, Roslyn returns an empty namespace). AreNamesEqual
+ // handles that by falling back to name-only comparison and also recurses into
+ // generic type arguments.
+ if (!candidate.Parameters[i].Type.AreNamesEqual(parameters[i].Type))
+ {
+ match = false;
+ break;
+ }
+ }
+
+ if (!match)
+ {
+ continue;
+ }
+
+ if (firstMatch != null)
+ {
+ // C# itself disallows duplicate signatures in the same type, so this should be
+ // unreachable in well-formed custom code. Surface it as a clear build-time error
+ // rather than silently picking one declaration over another.
+ throw new InvalidOperationException(
+ $"Multiple partial method declarations on '{enclosingType.Type.Name}' match the generated method '{methodName}' " +
+ $"with {parameters.Count} parameter(s). Each generated method may be customized by at most one partial declaration.");
+ }
+
+ firstMatch = candidate;
+ }
+
+ customSignature = firstMatch;
+ return firstMatch != null;
+ }
+
+ ///
+ /// Produces a new parameter list whose entries take their names (and modifier flags such
+ /// as default value, ref/out/in/params) from while
+ /// preserving every other piece of generator metadata from
+ /// (such as , ,
+ /// , ,
+ /// validation, property/field references, and so on).
+ ///
+ /// The parameters originally produced by the generator.
+ /// Body construction depends on the metadata carried by these instances.
+ /// The parameters from the customer's partial signature.
+ /// Must have the same count as .
+ /// When true, drop default values on the cloned
+ /// parameters. Required for partial method implementations.
+ public static IReadOnlyList RenameAndCloneParameters(
+ IReadOnlyList generatorParameters,
+ IReadOnlyList customParameters,
+ bool removeDefaults)
+ {
+ if (generatorParameters is null) throw new ArgumentNullException(nameof(generatorParameters));
+ if (customParameters is null) throw new ArgumentNullException(nameof(customParameters));
+ if (generatorParameters.Count != customParameters.Count)
+ {
+ throw new ArgumentException(
+ $"Parameter counts differ ({generatorParameters.Count} vs {customParameters.Count}).",
+ nameof(customParameters));
+ }
+
+ var renamed = new ParameterProvider[generatorParameters.Count];
+ for (int i = 0; i < generatorParameters.Count; i++)
+ {
+ renamed[i] = CloneParameterWithName(
+ generatorParameters[i],
+ customParameters[i].Name,
+ removeDefaults);
+ }
+
+ return renamed;
+ }
+
+ ///
+ /// Builds a for a partial method implementation using
+ /// (modifiers, name, return type, attributes, and
+ /// other signature metadata) and the supplied .
+ /// The result has applied.
+ ///
+ /// The customer's partial declaration signature.
+ /// The parameters to use for the implementation.
+ /// Must all be required (no default values) per the C# partial method rules.
+ public static MethodSignature BuildPartialSignature(
+ MethodSignature customSignature,
+ IReadOnlyList implementationParameters)
+ {
+ if (customSignature is null) throw new ArgumentNullException(nameof(customSignature));
+ if (implementationParameters is null) throw new ArgumentNullException(nameof(implementationParameters));
+
+ return new MethodSignature(
+ customSignature.Name,
+ customSignature.Description,
+ customSignature.Modifiers | MethodSignatureModifiers.Partial,
+ customSignature.ReturnType,
+ customSignature.ReturnDescription,
+ implementationParameters,
+ customSignature.Attributes,
+ customSignature.GenericArguments,
+ customSignature.GenericParameterConstraints,
+ customSignature.ExplicitInterface,
+ customSignature.NonDocumentComment);
+ }
+
+ // Clones a ParameterProvider with a new name (and optionally without its default value)
+ // while preserving all generator metadata. Returns the source unchanged when no change is
+ // needed.
+ private static ParameterProvider CloneParameterWithName(
+ ParameterProvider source,
+ string newName,
+ bool removeDefault)
+ {
+ if (source.Name == newName && !(removeDefault && source.DefaultValue != null))
+ {
+ return source;
+ }
+
+ return new ParameterProvider(
+ newName,
+ source.Description,
+ source.Type,
+ defaultValue: removeDefault ? null : source.DefaultValue,
+ isRef: source.IsRef,
+ isOut: source.IsOut,
+ isIn: source.IsIn,
+ isParams: source.IsParams,
+ attributes: source.Attributes,
+ property: source.Property,
+ field: source.Field,
+ initializationValue: source.InitializationValue,
+ location: source.Location,
+ wireInfo: source.WireInfo,
+ validation: source.Validation,
+ inputParameter: source.InputParameter)
+ {
+ SpreadSource = source.SpreadSource,
+ };
+ }
+ }
+}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs
index 2031d0e11f2..ba47908cd82 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs
@@ -331,8 +331,50 @@ internal FieldProvider[] FilterCustomizedFields(IEnumerable specF
internal MethodProvider[] FilterCustomizedMethods(IEnumerable specMethods)
{
var methods = new List();
+ var customMethods = CustomCodeView?.Methods;
+ // Only build the partial-declarations list when there are custom methods to inspect.
+ // The vast majority of TypeProviders have no custom code; skip the allocation in that case.
+ List? partialDeclarations = null;
+ if (customMethods != null && customMethods.Count > 0)
+ {
+ foreach (var customMethod in customMethods)
+ {
+ if (customMethod.IsPartialMethod)
+ {
+ (partialDeclarations ??= new List()).Add(customMethod);
+ }
+ }
+ }
+
foreach (var method in specMethods)
{
+ // If a generated method is already marked as partial (e.g., by
+ // ScmMethodProviderCollection's early detection), keep it as-is.
+ if (method.IsPartialMethod)
+ {
+ methods.Add(method);
+ continue;
+ }
+
+ MethodProvider? matchingPartial = null;
+ if (partialDeclarations != null)
+ {
+ foreach (var partial in partialDeclarations)
+ {
+ if (MethodSignatureBase.SignatureComparer.Equals(partial.Signature, method.Signature))
+ {
+ matchingPartial = partial;
+ break;
+ }
+ }
+ }
+
+ if (matchingPartial != null)
+ {
+ methods.Add(CreatePartialMethodFromCustomSignature(matchingPartial.Signature, method));
+ continue;
+ }
+
if (ShouldGenerate(method))
{
methods.Add(method);
@@ -342,6 +384,23 @@ internal MethodProvider[] FilterCustomizedMethods(IEnumerable sp
return [.. methods];
}
+ private static MethodProvider CreatePartialMethodFromCustomSignature(MethodSignature customSignature, MethodProvider generatedMethod)
+ {
+ // Partial method implementations require all parameters to be required (no default values).
+ var requiredParameters = PartialMethodCustomization.RenameAndCloneParameters(
+ customSignature.Parameters,
+ customSignature.Parameters,
+ removeDefaults: true);
+
+ var partialSignature = PartialMethodCustomization.BuildPartialSignature(customSignature, requiredParameters);
+
+ MethodProvider partialMethod = generatedMethod.BodyExpression != null
+ ? new MethodProvider(partialSignature, generatedMethod.BodyExpression, generatedMethod.EnclosingType, generatedMethod.XmlDocs, generatedMethod.Suppressions)
+ : new MethodProvider(partialSignature, generatedMethod.BodyStatements ?? MethodBodyStatement.Empty, generatedMethod.EnclosingType, generatedMethod.XmlDocs, generatedMethod.Suppressions);
+
+ return partialMethod;
+ }
+
internal ConstructorProvider[] FilterCustomizedConstructors(IEnumerable specConstructors)
{
var constructors = new List();
@@ -671,6 +730,13 @@ private bool ShouldGenerate(MethodProvider method)
var customMethods = method.EnclosingType.CustomCodeView?.Methods ?? [];
foreach (var customMethod in customMethods)
{
+ // Partial method declarations are handled in FilterCustomizedMethods and
+ // should not suppress the generated method.
+ if (customMethod.IsPartialMethod)
+ {
+ continue;
+ }
+
if (MethodSignatureBase.SignatureComparer.Equals(customMethod.Signature, method.Signature))
{
return false;
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs
index 7dc16fe4a34..9b262d69f13 100644
--- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs
@@ -333,6 +333,43 @@ public void ValidateParameterIsRef(bool isRef)
Assert.AreEqual(isRef, parameter.IsRef);
}
+ [Test]
+ public async Task ValidatePartialMethodIsDetected()
+ {
+ var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
+ compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
+ var compilation = mockGenerator.Object.SourceInputModel.Customization;
+ Assert.IsNotNull(compilation);
+
+ var symbol = CompilationHelper.GetSymbol(compilation!.Assembly.Modules.First().GlobalNamespace, "WithPartial")!;
+ var provider = new NamedTypeSymbolProvider(symbol, compilation);
+
+ var partial = provider.Methods.Single(m => m.Signature.Name == "DoIt");
+ Assert.IsTrue(partial.IsPartialMethod, "Expected DoIt to be detected as partial.");
+ Assert.IsTrue(partial.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial));
+
+ var nonPartial = provider.Methods.Single(m => m.Signature.Name == "NonPartial");
+ Assert.IsFalse(nonPartial.IsPartialMethod, "Expected NonPartial to not be detected as partial.");
+ Assert.IsFalse(nonPartial.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial));
+ }
+
+ [Test]
+ public async Task ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration()
+ {
+ // A partial method *with* a body is the implementation half - not the declaration we
+ // want to treat as a customization signal.
+ var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
+ compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
+ var compilation = mockGenerator.Object.SourceInputModel.Customization;
+ Assert.IsNotNull(compilation);
+
+ var symbol = CompilationHelper.GetSymbol(compilation!.Assembly.Modules.First().GlobalNamespace, "WithPartial")!;
+ var provider = new NamedTypeSymbolProvider(symbol, compilation);
+
+ var doIt = provider.Methods.Single(m => m.Signature.Name == "DoIt");
+ Assert.IsFalse(doIt.IsPartialMethod, "Partial methods with bodies should not be treated as customization signals.");
+ }
+
[Test]
public void ValidateMethods()
{
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodIsDetected/WithPartial.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodIsDetected/WithPartial.cs
new file mode 100644
index 00000000000..83b24a48b63
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodIsDetected/WithPartial.cs
@@ -0,0 +1,9 @@
+namespace Sample
+{
+ public partial class WithPartial
+ {
+ public partial void DoIt(int value);
+
+ public void NonPartial(int value) { }
+ }
+}
diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration/WithPartial.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration/WithPartial.cs
new file mode 100644
index 00000000000..86c55297462
--- /dev/null
+++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration/WithPartial.cs
@@ -0,0 +1,7 @@
+namespace Sample
+{
+ public partial class WithPartial
+ {
+ public partial void DoIt(int value) { /* body */ }
+ }
+}