From 6770d29fefa054bf426695400ae82b9a589d71fb Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 28 Apr 2026 17:58:37 -0700 Subject: [PATCH 01/10] feat(http-client-csharp): support partial method customization Allow library authors to customize a generated method's signature by declaring a partial method in custom code. The generator detects the declaration via Roslyn syntax, applies the custom signature (modifiers, name, parameter names) to the generated implementation, marks both as `partial`, and ensures all parameters are required as required by C#. For client protocol methods, the matching happens in ScmMethodProviderCollection.BuildProtocolMethod so that the generated body references the customized parameter names. For other methods, TypeProvider.FilterCustomizedMethods performs the substitution. Refs microsoft/typespec#10526, microsoft/typespec#8627 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Providers/ScmMethodProviderCollection.cs | 125 ++++++++++++++++-- .../ClientProviderCustomizationTests.cs | 41 ++++++ .../CanCustomizeMethodSignature.cs | 12 ++ .../src/Providers/MethodProvider.cs | 7 + .../src/Providers/NamedTypeSymbolProvider.cs | 27 +++- .../src/Providers/TypeProvider.cs | 62 +++++++++ 6 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodSignature/CanCustomizeMethodSignature.cs 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..2e84b133d6f 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 @@ -933,21 +933,70 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod } ParameterProvider[] parameters = [.. requiredParameters, .. optionalParameters, requestOptionsParameter]; + var methodName = isAsync ? ServiceMethod.Name + "Async" : ServiceMethod.Name; - 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); + // 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. + var customSignature = FindPartialMethodSignature(client, methodName, parameters); + bool isPartialMethod = false; + + MethodSignature methodSignature; + ParameterProvider[] bodyParameters; + + if (customSignature != null) + { + isPartialMethod = true; + + // Partial methods cannot have optional parameters in the implementation. + var requiredCustomParameters = customSignature.Parameters + .Select(p => p.DefaultValue != null + ? new ParameterProvider(p.Name, p.Description, p.Type, defaultValue: null, + isRef: p.IsRef, isOut: p.IsOut, isIn: p.IsIn, isParams: p.IsParams, + attributes: p.Attributes, property: p.Property) + { + Validation = p.Validation, + Field = p.Field, + } + : p) + .ToArray(); + + methodSignature = new MethodSignature( + customSignature.Name, + customSignature.Description, + customSignature.Modifiers | MethodSignatureModifiers.Partial, + customSignature.ReturnType, + customSignature.ReturnDescription, + requiredCustomParameters, + customSignature.Attributes, + customSignature.GenericArguments, + customSignature.GenericParameterConstraints, + customSignature.ExplicitInterface, + customSignature.NonDocumentComment); + + 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 +1005,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))) ]; @@ -965,6 +1014,11 @@ [.. parameters.Select(p => (ValueExpression)p)]), out var message), var protocolMethod = new ScmMethodProvider(methodSignature, methodBody, EnclosingType, ScmMethodKind.Protocol, collectionDefinition: collection, serviceMethod: ServiceMethod); + if (isPartialMethod) + { + protocolMethod.IsPartialMethod = true; + } + if (protocolMethod.XmlDocs != null) { var exceptions = new List(protocolMethod.XmlDocs.Exceptions); @@ -982,6 +1036,59 @@ [.. parameters.Select(p => (ValueExpression)p)]), out var message), return protocolMethod; } + private MethodSignature? FindPartialMethodSignature(ClientProvider client, string methodName, IReadOnlyList parameters) + { + var customMethods = client.CustomCodeView?.Methods; + if (customMethods == null || customMethods.Count == 0) + { + return null; + } + + foreach (var customMethod in customMethods) + { + if (!customMethod.IsPartialMethod) + { + continue; + } + + var customSignature = customMethod.Signature; + if (customSignature.Name != methodName || customSignature.Parameters.Count != parameters.Count) + { + continue; + } + + bool match = true; + for (int i = 0; i < parameters.Count; i++) + { + if (!IsTypeNameMatch(customSignature.Parameters[i].Type, parameters[i].Type)) + { + match = false; + break; + } + } + + if (match) + { + return customSignature; + } + } + + return null; + } + + private static bool IsTypeNameMatch(CSharpType typeFromCustomization, CSharpType generatedType) + { + // The namespace may not be available for generated types referenced from customization as they + // are not yet generated, so Roslyn will not have the namespace information. + if (string.IsNullOrEmpty(typeFromCustomization.Namespace)) + { + return typeFromCustomization.Name == generatedType.Name; + } + + return typeFromCustomization.Namespace == generatedType.Namespace + && typeFromCustomization.Name == generatedType.Name; + } + private ParameterProvider ProcessOptionalParameters( List optionalParameters, List requiredParameters, 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..ec266fb3232 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,46 @@ 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); + } } } 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..ce81e8b9b46 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,13 @@ public class MethodProvider public IReadOnlyList Suppressions { get; internal set; } + /// + /// Indicates whether this method should be generated as a partial method. + /// When true, custom code provides the signature declaration and the generator + /// emits a matching partial implementation. + /// + public bool IsPartialMethod { get; set; } + // 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..92be70e9652 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 @@ -287,11 +294,29 @@ protected internal override MethodProvider[] BuildMethods() [.. methodSymbol.Parameters.Select(p => ConvertToParameterProvider(methodSymbol, p))], ExplicitInterface: explicitInterface?.ContainingType?.GetCSharpType()); - methods.Add(new MethodProvider(signature, MethodBodyStatement.Empty, this)); + methods.Add(new MethodProvider(signature, MethodBodyStatement.Empty, this) { IsPartialMethod = isPartialDeclaration }); } 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/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 2031d0e11f2..f3a7552a9fb 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,27 @@ internal FieldProvider[] FilterCustomizedFields(IEnumerable specF internal MethodProvider[] FilterCustomizedMethods(IEnumerable specMethods) { var methods = new List(); + var customMethods = CustomCodeView?.Methods ?? []; + var partialDeclarations = customMethods.Where(m => m.IsPartialMethod).ToList(); + 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; + } + + var matchingPartial = partialDeclarations + .FirstOrDefault(p => MethodSignatureBase.SignatureComparer.Equals(p.Signature, method.Signature)); + if (matchingPartial != null) + { + methods.Add(CreatePartialMethodFromCustomSignature(matchingPartial.Signature, method)); + continue; + } + if (ShouldGenerate(method)) { methods.Add(method); @@ -342,6 +361,42 @@ 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 = customSignature.Parameters + .Select(p => p.DefaultValue != null + ? new ParameterProvider(p.Name, p.Description, p.Type, defaultValue: null, + isRef: p.IsRef, isOut: p.IsOut, isIn: p.IsIn, isParams: p.IsParams, + attributes: p.Attributes, property: p.Property) + { + Validation = p.Validation, + Field = p.Field, + } + : p) + .ToList(); + + var partialSignature = new MethodSignature( + customSignature.Name, + customSignature.Description, + customSignature.Modifiers | MethodSignatureModifiers.Partial, + customSignature.ReturnType, + customSignature.ReturnDescription, + requiredParameters, + customSignature.Attributes, + customSignature.GenericArguments, + customSignature.GenericParameterConstraints, + customSignature.ExplicitInterface, + customSignature.NonDocumentComment); + + 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); + + partialMethod.IsPartialMethod = true; + return partialMethod; + } + internal ConstructorProvider[] FilterCustomizedConstructors(IEnumerable specConstructors) { var constructors = new List(); @@ -671,6 +726,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; From 4b030fce5489b3490831cc261a4c8a627731b18a Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 28 Apr 2026 18:06:10 -0700 Subject: [PATCH 02/10] feat(http-client-csharp): extend partial method customization to convenience methods Convenience methods reference the convenience parameter providers directly when building their bodies (param conversions, request invocation, etc.). To allow renaming parameters via a partial declaration, BuildConvenienceMethod now performs the same early signature detection as BuildProtocolMethod and clones each parameter with the customer-chosen name while preserving all generator metadata (Location, WireInfo, SpreadSource, InputParameter, ...) so the body construction continues to work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Providers/ScmMethodProviderCollection.cs | 110 ++++++++++++++++-- 1 file changed, 99 insertions(+), 11 deletions(-) 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 2e84b133d6f..10da8eb8d71 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,81 @@ 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. + var customSignature = FindPartialMethodSignature(client, methodName, signatureParameters); + bool isPartialMethod = false; + + // 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. + ParameterProvider[] convenienceBodyParameters = [.. ConvenienceMethodParameters]; + + MethodSignature methodSignature; + + if (customSignature != null) + { + isPartialMethod = true; + + var renamedSignatureParameters = new ParameterProvider[signatureParameters.Length]; + for (int i = 0; i < signatureParameters.Length; i++) + { + renamedSignatureParameters[i] = CloneParameterWithName(signatureParameters[i], customSignature.Parameters[i].Name, removeDefault: true); + } + + // The generator-controlled body params are the leading parameters (everything except the trailing CancellationToken). + convenienceBodyParameters = new ParameterProvider[ConvenienceMethodParameters.Count]; + for (int i = 0; i < ConvenienceMethodParameters.Count; i++) + { + convenienceBodyParameters[i] = renamedSignatureParameters[i]; + } + + methodSignature = new MethodSignature( + customSignature.Name, + customSignature.Description, + customSignature.Modifiers | MethodSignatureModifiers.Partial, + customSignature.ReturnType, + customSignature.ReturnDescription, + renamedSignatureParameters, + customSignature.Attributes, + customSignature.GenericArguments, + customSignature.GenericParameterConstraints, + customSignature.ExplicitInterface, + customSignature.NonDocumentComment); + } + else + { + 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 +192,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) @@ -167,6 +217,11 @@ .. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, var convenienceMethod = new ScmMethodProvider(methodSignature, methodBody, EnclosingType, ScmMethodKind.Convenience, collectionDefinition: collection, serviceMethod: ServiceMethod); + if (isPartialMethod) + { + convenienceMethod.IsPartialMethod = true; + } + if (convenienceMethod.XmlDocs != null) { var exceptions = new List(convenienceMethod.XmlDocs.Exceptions); @@ -177,6 +232,39 @@ .. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, return convenienceMethod; } + // Clones a ParameterProvider with a new name while preserving all generator metadata + // (Location, WireInfo, SpreadSource, InputParameter, etc.). Used when applying a user's + // partial method declaration so that body construction (which references the parameters + // by their providers) emits code referring to the customer-chosen names. + 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, + }; + } + private IEnumerable GetStackVariablesForProtocolParamConversion(IReadOnlyList convenienceMethodParameters, out Dictionary declarations) { List statements = new List(); From cb30b51ae08eebda86cd74d37ffeac33f205e975 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:28:53 -0700 Subject: [PATCH 03/10] refactor(http-client-csharp): extract PartialMethodCustomization helper Pull the partial-method matching, parameter cloning, and signature construction logic out of ScmMethodProviderCollection (and TypeProvider) into a new public PartialMethodCustomization static class in Microsoft.TypeSpec.Generator. This gives downstream emitters such as the management emitter -- which wrap ClientProviders in their own public-surface providers and so can't piggy-back on the unbranded matching logic -- a stable API to consume. Also adds a defensive throw when more than one partial declaration would match the same generated method (unreachable in well-formed C# but clearer than silently picking the first one). No behavior change for protocol or convenience methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Providers/ScmMethodProviderCollection.cs | 142 ++--------- .../Providers/PartialMethodCustomization.cs | 227 ++++++++++++++++++ .../src/Providers/TypeProvider.cs | 30 +-- 3 files changed, 247 insertions(+), 152 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs 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 10da8eb8d71..5a9ee875ba3 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 @@ -119,7 +119,8 @@ private ScmMethodProvider BuildConvenienceMethod(MethodProvider protocolMethod, ParameterProvider[] signatureParameters = [.. ConvenienceMethodParameters, ScmKnownParameters.CancellationToken]; // Detect a partial method declaration in the client's custom code matching this convenience method. - var customSignature = FindPartialMethodSignature(client, methodName, signatureParameters); + MethodSignature? customSignature = null; + PartialMethodCustomization.TryFindCustomSignature(client, methodName, signatureParameters, out customSignature); bool isPartialMethod = false; // Parameters used to construct the method body. When customizing, we clone the generator's @@ -133,11 +134,10 @@ private ScmMethodProvider BuildConvenienceMethod(MethodProvider protocolMethod, { isPartialMethod = true; - var renamedSignatureParameters = new ParameterProvider[signatureParameters.Length]; - for (int i = 0; i < signatureParameters.Length; i++) - { - renamedSignatureParameters[i] = CloneParameterWithName(signatureParameters[i], customSignature.Parameters[i].Name, removeDefault: true); - } + var renamedSignatureParameters = PartialMethodCustomization.RenameAndCloneParameters( + signatureParameters, + customSignature.Parameters, + removeDefaults: true); // The generator-controlled body params are the leading parameters (everything except the trailing CancellationToken). convenienceBodyParameters = new ParameterProvider[ConvenienceMethodParameters.Count]; @@ -146,18 +146,7 @@ private ScmMethodProvider BuildConvenienceMethod(MethodProvider protocolMethod, convenienceBodyParameters[i] = renamedSignatureParameters[i]; } - methodSignature = new MethodSignature( - customSignature.Name, - customSignature.Description, - customSignature.Modifiers | MethodSignatureModifiers.Partial, - customSignature.ReturnType, - customSignature.ReturnDescription, - renamedSignatureParameters, - customSignature.Attributes, - customSignature.GenericArguments, - customSignature.GenericParameterConstraints, - customSignature.ExplicitInterface, - customSignature.NonDocumentComment); + methodSignature = PartialMethodCustomization.BuildPartialSignature(customSignature, renamedSignatureParameters); } else { @@ -232,39 +221,6 @@ .. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, return convenienceMethod; } - // Clones a ParameterProvider with a new name while preserving all generator metadata - // (Location, WireInfo, SpreadSource, InputParameter, etc.). Used when applying a user's - // partial method declaration so that body construction (which references the parameters - // by their providers) emits code referring to the customer-chosen names. - 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, - }; - } - private IEnumerable GetStackVariablesForProtocolParamConversion(IReadOnlyList convenienceMethodParameters, out Dictionary declarations) { List statements = new List(); @@ -1026,7 +982,8 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod // 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. - var customSignature = FindPartialMethodSignature(client, methodName, parameters); + MethodSignature? customSignature = null; + PartialMethodCustomization.TryFindCustomSignature(client, methodName, parameters, out customSignature); bool isPartialMethod = false; MethodSignature methodSignature; @@ -1037,30 +994,12 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod isPartialMethod = true; // Partial methods cannot have optional parameters in the implementation. - var requiredCustomParameters = customSignature.Parameters - .Select(p => p.DefaultValue != null - ? new ParameterProvider(p.Name, p.Description, p.Type, defaultValue: null, - isRef: p.IsRef, isOut: p.IsOut, isIn: p.IsIn, isParams: p.IsParams, - attributes: p.Attributes, property: p.Property) - { - Validation = p.Validation, - Field = p.Field, - } - : p) - .ToArray(); + var requiredCustomParameters = PartialMethodCustomization.RenameAndCloneParameters( + customSignature.Parameters, + customSignature.Parameters, + removeDefaults: true).ToArray(); - methodSignature = new MethodSignature( - customSignature.Name, - customSignature.Description, - customSignature.Modifiers | MethodSignatureModifiers.Partial, - customSignature.ReturnType, - customSignature.ReturnDescription, - requiredCustomParameters, - customSignature.Attributes, - customSignature.GenericArguments, - customSignature.GenericParameterConstraints, - customSignature.ExplicitInterface, - customSignature.NonDocumentComment); + methodSignature = PartialMethodCustomization.BuildPartialSignature(customSignature, requiredCustomParameters); bodyParameters = requiredCustomParameters; // Re-resolve the request options parameter from the customized parameter list so the @@ -1124,59 +1063,6 @@ [.. bodyParameters.Select(p => (ValueExpression)p)]), out var message), return protocolMethod; } - private MethodSignature? FindPartialMethodSignature(ClientProvider client, string methodName, IReadOnlyList parameters) - { - var customMethods = client.CustomCodeView?.Methods; - if (customMethods == null || customMethods.Count == 0) - { - return null; - } - - foreach (var customMethod in customMethods) - { - if (!customMethod.IsPartialMethod) - { - continue; - } - - var customSignature = customMethod.Signature; - if (customSignature.Name != methodName || customSignature.Parameters.Count != parameters.Count) - { - continue; - } - - bool match = true; - for (int i = 0; i < parameters.Count; i++) - { - if (!IsTypeNameMatch(customSignature.Parameters[i].Type, parameters[i].Type)) - { - match = false; - break; - } - } - - if (match) - { - return customSignature; - } - } - - return null; - } - - private static bool IsTypeNameMatch(CSharpType typeFromCustomization, CSharpType generatedType) - { - // The namespace may not be available for generated types referenced from customization as they - // are not yet generated, so Roslyn will not have the namespace information. - if (string.IsNullOrEmpty(typeFromCustomization.Namespace)) - { - return typeFromCustomization.Name == generatedType.Name; - } - - return typeFromCustomization.Namespace == generatedType.Namespace - && typeFromCustomization.Name == generatedType.Name; - } - private ParameterProvider ProcessOptionalParameters( List optionalParameters, List requiredParameters, 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..0842a0a9396 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PartialMethodCustomization.cs @@ -0,0 +1,227 @@ +// 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++) + { + if (!IsTypeNameMatch(candidate.Parameters[i].Type, 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, + }; + } + + // Compares parameter types by name. The namespace may not be available for generated types + // referenced from customization since they aren't yet generated, so Roslyn won't have the + // namespace information; in that case we fall back to a name-only comparison. + private static bool IsTypeNameMatch(CSharpType typeFromCustomization, CSharpType generatedType) + { + if (string.IsNullOrEmpty(typeFromCustomization.Namespace)) + { + return typeFromCustomization.Name == generatedType.Name; + } + + return typeFromCustomization.Namespace == generatedType.Namespace + && typeFromCustomization.Name == generatedType.Name; + } + } +} 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 f3a7552a9fb..aa61c062603 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 @@ -364,30 +364,12 @@ internal MethodProvider[] FilterCustomizedMethods(IEnumerable sp private static MethodProvider CreatePartialMethodFromCustomSignature(MethodSignature customSignature, MethodProvider generatedMethod) { // Partial method implementations require all parameters to be required (no default values). - var requiredParameters = customSignature.Parameters - .Select(p => p.DefaultValue != null - ? new ParameterProvider(p.Name, p.Description, p.Type, defaultValue: null, - isRef: p.IsRef, isOut: p.IsOut, isIn: p.IsIn, isParams: p.IsParams, - attributes: p.Attributes, property: p.Property) - { - Validation = p.Validation, - Field = p.Field, - } - : p) - .ToList(); - - var partialSignature = new MethodSignature( - customSignature.Name, - customSignature.Description, - customSignature.Modifiers | MethodSignatureModifiers.Partial, - customSignature.ReturnType, - customSignature.ReturnDescription, - requiredParameters, - customSignature.Attributes, - customSignature.GenericArguments, - customSignature.GenericParameterConstraints, - customSignature.ExplicitInterface, - customSignature.NonDocumentComment); + 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) From aea5fb39a7fbbf50183b974efef1969d33dc0d38 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:31:04 -0700 Subject: [PATCH 04/10] docs(http-client-csharp): document partial method customization Adds a new section to the customization guide explaining when to use partial method declarations to customize a generated method's signature, what is supported (modifier changes, parameter renames), and what is not (method renames, parameter list changes, defaults on the impl). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.tspd/docs/customization.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/http-client-csharp/.tspd/docs/customization.md b/packages/http-client-csharp/.tspd/docs/customization.md index 87286f97a19..0812924db06 100644 --- a/packages/http-client-csharp/.tspd/docs/customization.md +++ b/packages/http-client-csharp/.tspd/docs/customization.md @@ -1273,6 +1273,83 @@ namespace Azure.Service.Operations +## Customize a generated method's signature + +Declare a `partial` method (without a body) on the generated client (or any other generated `TypeProvider`) with the same name and parameter types as the generated method. The generator emits a matching partial implementation, taking the modifiers and parameter names from your declaration while keeping its generated body. This works for protocol methods, convenience methods, and other methods on `TypeProvider`s such as model factories. + +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. + +### 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. +- **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. + +
+ +**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. From 77e8ad68a78f2de32c637b912ab517b7975957a9 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:35:09 -0700 Subject: [PATCH 05/10] test(http-client-csharp): add modifier-only and convenience method partial customization tests - CanCustomizeMethodModifierOnly: verifies a partial decl that only changes the access modifier (public -> internal) without renaming parameters still produces a partial impl with the correct modifier and the generator's parameter names. - CanCustomizeConvenienceMethodSignature: covers the BuildConvenienceMethod matching/cloning path. A partial decl on the convenience overload renames p1 -> body and cancellationToken -> ct; verifies both names are applied and CancellationToken metadata is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ClientProviderCustomizationTests.cs | 89 +++++++++++++++++++ .../CanCustomizeConvenienceMethodSignature.cs | 14 +++ .../CanCustomizeMethodModifierOnly.cs | 12 +++ 3 files changed, 115 insertions(+) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeConvenienceMethodSignature/CanCustomizeConvenienceMethodSignature.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodModifierOnly/CanCustomizeMethodModifierOnly.cs 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 ec266fb3232..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 @@ -375,5 +375,94 @@ public async Task CanCustomizeMethodSignature() && 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); + } +} From c21e378ac344156d500aeb3bb6a4376ce2208b0a Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:38:03 -0700 Subject: [PATCH 06/10] docs(http-client-csharp): clarify partial method customization is for client methods Narrows the scope explanation: the feature is targeted at protocol and convenience methods on the generated *Client class. Other generated members (models, serialization, model factories) should be customized using [CodeGenSuppress] + a hand-written replacement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-csharp/.tspd/docs/customization.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/http-client-csharp/.tspd/docs/customization.md b/packages/http-client-csharp/.tspd/docs/customization.md index 0812924db06..a69517b4e82 100644 --- a/packages/http-client-csharp/.tspd/docs/customization.md +++ b/packages/http-client-csharp/.tspd/docs/customization.md @@ -1273,9 +1273,11 @@ namespace Azure.Service.Operations -## Customize a generated method's signature +## Customize a generated client method's signature -Declare a `partial` method (without a body) on the generated client (or any other generated `TypeProvider`) with the same name and parameter types as the generated method. The generator emits a matching partial implementation, taking the modifiers and parameter names from your declaration while keeping its generated body. This works for protocol methods, convenience methods, and other methods on `TypeProvider`s such as model factories. +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: @@ -1289,12 +1291,14 @@ This is preferred over `[CodeGenSuppress]` + a hand-written replacement when the - **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. - **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.
From ed2b149033a1373bffa1f1ffe425404c2cd79a63 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:45:34 -0700 Subject: [PATCH 07/10] perf(http-client-csharp): skip partial-decl scan when type provider has no custom code In FilterCustomizedMethods, only allocate the partial-declarations list when the TypeProvider actually has custom methods to inspect, and only walk it for matches when there is at least one partial declaration. This avoids one List allocation and a per-generated-method FirstOrDefault delegate allocation for the vast majority of TypeProviders that have no custom code at all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Providers/TypeProvider.cs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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 aa61c062603..6e68946fa73 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,20 @@ internal FieldProvider[] FilterCustomizedFields(IEnumerable specF internal MethodProvider[] FilterCustomizedMethods(IEnumerable specMethods) { var methods = new List(); - var customMethods = CustomCodeView?.Methods ?? []; - var partialDeclarations = customMethods.Where(m => m.IsPartialMethod).ToList(); + 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) { @@ -344,8 +356,19 @@ internal MethodProvider[] FilterCustomizedMethods(IEnumerable sp continue; } - var matchingPartial = partialDeclarations - .FirstOrDefault(p => MethodSignatureBase.SignatureComparer.Equals(p.Signature, method.Signature)); + 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)); From 89bc1e18a2aecf2f14ce37e30025361f731d4797 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 08:47:29 -0700 Subject: [PATCH 08/10] docs(http-client-csharp): clarify parameter type changes are not supported Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-csharp/.tspd/docs/customization.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http-client-csharp/.tspd/docs/customization.md b/packages/http-client-csharp/.tspd/docs/customization.md index a69517b4e82..c2ff40663e2 100644 --- a/packages/http-client-csharp/.tspd/docs/customization.md +++ b/packages/http-client-csharp/.tspd/docs/customization.md @@ -1296,6 +1296,7 @@ This is preferred over `[CodeGenSuppress]` + a hand-written replacement when the ### 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. From 2e119210aaf95bc928faa5499d83bb3496217e8a Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 10:42:34 -0700 Subject: [PATCH 09/10] address PR feedback - Derive MethodProvider.IsPartialMethod from MethodSignatureModifiers.Partial rather than carrying a separate bool, removing the redundant assignments. - Replace the local IsTypeNameMatch helper with the existing CSharpType.AreNamesEqual API, which already implements the empty-namespace fallback and additionally recurses into generic type arguments. - Avoid the [.. ConvenienceMethodParameters] allocation in BuildConvenienceMethod when no partial customization is present (common case). - Add NamedTypeSymbolProvider tests covering both the partial-declaration detection and the case where a partial method already carries a body. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Providers/ScmMethodProviderCollection.cs | 24 ++------ .../src/Providers/MethodProvider.cs | 7 +-- .../src/Providers/NamedTypeSymbolProvider.cs | 2 +- .../Providers/PartialMethodCustomization.cs | 20 ++----- .../src/Providers/TypeProvider.cs | 1 - .../NamedTypeSymbolProviderTests.cs | 59 +++++++++++++++++++ 6 files changed, 73 insertions(+), 40 deletions(-) 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 5a9ee875ba3..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 @@ -121,35 +121,34 @@ private ScmMethodProvider BuildConvenienceMethod(MethodProvider protocolMethod, // 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); - bool isPartialMethod = false; // 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. - ParameterProvider[] convenienceBodyParameters = [.. ConvenienceMethodParameters]; + IReadOnlyList convenienceBodyParameters; MethodSignature methodSignature; if (customSignature != null) { - isPartialMethod = true; - var renamedSignatureParameters = PartialMethodCustomization.RenameAndCloneParameters( signatureParameters, customSignature.Parameters, removeDefaults: true); // The generator-controlled body params are the leading parameters (everything except the trailing CancellationToken). - convenienceBodyParameters = new ParameterProvider[ConvenienceMethodParameters.Count]; + var bodyParams = new ParameterProvider[ConvenienceMethodParameters.Count]; for (int i = 0; i < ConvenienceMethodParameters.Count; i++) { - convenienceBodyParameters[i] = renamedSignatureParameters[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), @@ -206,11 +205,6 @@ .. GetStackVariablesForReturnValueConversion(result, responseBodyType, isAsync, var convenienceMethod = new ScmMethodProvider(methodSignature, methodBody, EnclosingType, ScmMethodKind.Convenience, collectionDefinition: collection, serviceMethod: ServiceMethod); - if (isPartialMethod) - { - convenienceMethod.IsPartialMethod = true; - } - if (convenienceMethod.XmlDocs != null) { var exceptions = new List(convenienceMethod.XmlDocs.Exceptions); @@ -984,15 +978,12 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod // generated body using the customized parameter references. MethodSignature? customSignature = null; PartialMethodCustomization.TryFindCustomSignature(client, methodName, parameters, out customSignature); - bool isPartialMethod = false; MethodSignature methodSignature; ParameterProvider[] bodyParameters; if (customSignature != null) { - isPartialMethod = true; - // Partial methods cannot have optional parameters in the implementation. var requiredCustomParameters = PartialMethodCustomization.RenameAndCloneParameters( customSignature.Parameters, @@ -1041,11 +1032,6 @@ [.. bodyParameters.Select(p => (ValueExpression)p)]), out var message), var protocolMethod = new ScmMethodProvider(methodSignature, methodBody, EnclosingType, ScmMethodKind.Protocol, collectionDefinition: collection, serviceMethod: ServiceMethod); - if (isPartialMethod) - { - protocolMethod.IsPartialMethod = true; - } - if (protocolMethod.XmlDocs != null) { var exceptions = new List(protocolMethod.XmlDocs.Exceptions); 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 ce81e8b9b46..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 @@ -24,11 +24,10 @@ public class MethodProvider public IReadOnlyList Suppressions { get; internal set; } /// - /// Indicates whether this method should be generated as a partial method. - /// When true, custom code provides the signature declaration and the generator - /// emits a matching partial implementation. + /// Indicates whether this method is declared as a partial method. + /// Derived from the modifier on the signature. /// - public bool IsPartialMethod { get; set; } + 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. 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 92be70e9652..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 @@ -294,7 +294,7 @@ protected internal override MethodProvider[] BuildMethods() [.. methodSymbol.Parameters.Select(p => ConvertToParameterProvider(methodSymbol, p))], ExplicitInterface: explicitInterface?.ContainingType?.GetCSharpType()); - methods.Add(new MethodProvider(signature, MethodBodyStatement.Empty, this) { IsPartialMethod = isPartialDeclaration }); + methods.Add(new MethodProvider(signature, MethodBodyStatement.Empty, this)); } return [.. methods]; } 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 index 0842a0a9396..d86863450c3 100644 --- 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 @@ -76,7 +76,11 @@ public static bool TryFindCustomSignature( bool match = true; for (int i = 0; i < parameters.Count; i++) { - if (!IsTypeNameMatch(candidate.Parameters[i].Type, parameters[i].Type)) + // 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; @@ -209,19 +213,5 @@ private static ParameterProvider CloneParameterWithName( SpreadSource = source.SpreadSource, }; } - - // Compares parameter types by name. The namespace may not be available for generated types - // referenced from customization since they aren't yet generated, so Roslyn won't have the - // namespace information; in that case we fall back to a name-only comparison. - private static bool IsTypeNameMatch(CSharpType typeFromCustomization, CSharpType generatedType) - { - if (string.IsNullOrEmpty(typeFromCustomization.Namespace)) - { - return typeFromCustomization.Name == generatedType.Name; - } - - return typeFromCustomization.Namespace == generatedType.Namespace - && typeFromCustomization.Name == generatedType.Name; - } } } 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 6e68946fa73..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 @@ -398,7 +398,6 @@ private static MethodProvider CreatePartialMethodFromCustomSignature(MethodSigna ? new MethodProvider(partialSignature, generatedMethod.BodyExpression, generatedMethod.EnclosingType, generatedMethod.XmlDocs, generatedMethod.Suppressions) : new MethodProvider(partialSignature, generatedMethod.BodyStatements ?? MethodBodyStatement.Empty, generatedMethod.EnclosingType, generatedMethod.XmlDocs, generatedMethod.Suppressions); - partialMethod.IsPartialMethod = true; return partialMethod; } 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..fe12a146b02 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 @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; @@ -333,6 +334,64 @@ public void ValidateParameterIsRef(bool isRef) Assert.AreEqual(isRef, parameter.IsRef); } + [Test] + public void ValidatePartialMethodIsDetected() + { + const string source = @" +namespace Sample +{ + public partial class WithPartial + { + public partial void DoIt(int value); + public void NonPartial(int value) { } + } +}"; + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + assemblyName: "PartialTest", + syntaxTrees: [syntaxTree], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + + MockHelpers.LoadMockGenerator(); + 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 void ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration() + { + // A partial method *with* a body is the implementation half - not the declaration we + // want to treat as a customization signal. + const string source = @" +namespace Sample +{ + public partial class WithPartial + { + public partial void DoIt(int value) { /* body */ } + } +}"; + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + assemblyName: "PartialImplTest", + syntaxTrees: [syntaxTree], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + + MockHelpers.LoadMockGenerator(); + 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() { From 792511ea4343451448ccc7556ebbbc28f2a9832a Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 29 Apr 2026 11:02:09 -0700 Subject: [PATCH 10/10] test: use TestData pattern for partial method tests Move the inline C# source strings used in the new partial-method detection tests into TestData/// files, matching the convention used elsewhere in NamedTypeSymbolProviderTests (e.g. ValidateSelfReferentialGenericBaseType). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NamedTypeSymbolProviderTests.cs | 50 ++++++------------- .../WithPartial.cs | 9 ++++ .../WithPartial.cs | 7 +++ 3 files changed, 30 insertions(+), 36 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodIsDetected/WithPartial.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/TestData/NamedTypeSymbolProviderTests/ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration/WithPartial.cs 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 fe12a146b02..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 @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; @@ -335,25 +334,14 @@ public void ValidateParameterIsRef(bool isRef) } [Test] - public void ValidatePartialMethodIsDetected() + public async Task ValidatePartialMethodIsDetected() { - const string source = @" -namespace Sample -{ - public partial class WithPartial - { - public partial void DoIt(int value); - public void NonPartial(int value) { } - } -}"; - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var compilation = CSharpCompilation.Create( - assemblyName: "PartialTest", - syntaxTrees: [syntaxTree], - references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); - - MockHelpers.LoadMockGenerator(); - var symbol = CompilationHelper.GetSymbol(compilation.Assembly.Modules.First().GlobalNamespace, "WithPartial")!; + 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"); @@ -366,26 +354,16 @@ public void NonPartial(int value) { } } [Test] - public void ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration() + 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. - const string source = @" -namespace Sample -{ - public partial class WithPartial - { - public partial void DoIt(int value) { /* body */ } - } -}"; - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var compilation = CSharpCompilation.Create( - assemblyName: "PartialImplTest", - syntaxTrees: [syntaxTree], - references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); - - MockHelpers.LoadMockGenerator(); - var symbol = CompilationHelper.GetSymbol(compilation.Assembly.Modules.First().GlobalNamespace, "WithPartial")!; + 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"); 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 */ } + } +}