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 eea209b4917..7de2f346d9c 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 @@ -615,21 +615,61 @@ 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); + // Check for partial method customization in client's custom code + var customSignature = FindPartialMethodSignature(client, methodName, parameters); + + MethodSignature methodSignature; + bool isPartialMethod = false; + + if (customSignature != null) + { + // Use the custom signature but ensure all parameters are required for partial methods + 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) + .ToList(); + + 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); + + isPartialMethod = true; + } + 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); + } 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, methodSignature.Parameters.ToArray(), false)]; } else { @@ -661,9 +701,72 @@ private ScmMethodProvider BuildProtocolMethod(MethodProvider createRequestMethod protocolMethod.XmlDocs.Update(summary: summary, exceptions: exceptions); } + + if (isPartialMethod) + { + protocolMethod.IsPartialMethod = true; + } + return protocolMethod; } + private MethodSignature? FindPartialMethodSignature(ClientProvider client, string methodName, IReadOnlyList parameters) + { + // Check client's custom code view for partial method declarations + var customMethods = client.CustomCodeView?.Methods ?? []; + var partialMethods = customMethods.Where(m => m.IsPartialMethod); + + // Create a temporary signature for matching + var tempSignature = new MethodSignature( + methodName, + null, + MethodSignatureModifiers.Public | MethodSignatureModifiers.Virtual, + GetResponseType(ServiceMethod.Operation.Responses, false, methodName.EndsWith("Async"), out _), + null, + parameters); + + foreach (var partialMethod in partialMethods) + { + if (IsMatch(partialMethod.Signature, tempSignature)) + { + return partialMethod.Signature; + } + } + + return null; + } + + private static bool IsMatch(MethodSignatureBase customMethod, MethodSignatureBase method) + { + if (customMethod.Parameters.Count != method.Parameters.Count || customMethod.Name != method.Name) + { + return false; + } + + for (int i = 0; i < customMethod.Parameters.Count; i++) + { + if (!IsNameMatch(customMethod.Parameters[i].Type, method.Parameters[i].Type)) + { + return false; + } + } + + return true; + } + + private static bool IsNameMatch(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 d058de7cf6e..163a363430d 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 @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.ClientModel; using System.Collections.Generic; using System.Linq; @@ -326,5 +327,40 @@ public async Task CanRemoveCachingField() var cachingField = fields.SingleOrDefault(f => f.Name == "_cachedDog"); Assert.IsNull(cachingField); } + + // Validates that a method signature can be customized using partial methods + [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()); + + // Find the client provider + var clientProvider = mockGenerator.Object.OutputLibrary.TypeProviders.SingleOrDefault(t => t is ClientProvider); + Assert.IsNotNull(clientProvider); + + // The generated methods should include HelloAgain as a partial method (protocol method) + var clientProviderMethods = clientProvider!.Methods; + var partialMethod = clientProviderMethods.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"); + + // Verify it's marked as partial + Assert.IsTrue(partialMethod!.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Partial), "Method should have Partial modifier"); + + // Verify the signature + Assert.AreEqual("HelloAgain", partialMethod.Signature.Name); + Assert.AreEqual(2, partialMethod.Signature.Parameters.Count); + Assert.AreEqual("content", partialMethod.Signature.Parameters[0].Name, "Parameter name should match custom declaration"); + } } } 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..6fa47efd42f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/TestData/ClientProviderCustomizationTests/CanCustomizeMethodSignature/CanCustomizeMethodSignature.cs @@ -0,0 +1,17 @@ + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Sample +{ + /// + public partial class TestClient + { + // Partial method declaration - matches protocol method signature + public partial ClientResult HelloAgain(BinaryContent content, RequestOptions options); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/MethodSignatureModifiers.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/MethodSignatureModifiers.cs index 2a0f6b07ec8..ba9638c0578 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/MethodSignatureModifiers.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/MethodSignatureModifiers.cs @@ -21,6 +21,7 @@ public enum MethodSignatureModifiers Override = 512, Operator = 1024, Explicit = 2048, - Implicit = 4096 + Implicit = 4096, + Partial = 8192 } } 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 1576bec97a0..50124495bcc 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 should be generated as a partial method. + /// When true, the custom code provides the signature declaration and the generated code provides the 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 8f3be27a3e3..13f0a3b0b75 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 @@ -206,6 +206,14 @@ protected override MethodProvider[] BuildMethods() kindOptions: SymbolDisplayKindOptions.None); AddAdditionalModifiers(methodSymbol, ref modifiers); + + // Check if this is a partial method declaration (no body) + bool isPartialDeclaration = IsPartialMethodDeclaration(methodSymbol); + if (isPartialDeclaration) + { + modifiers |= MethodSignatureModifiers.Partial; + } + var explicitInterface = methodSymbol.ExplicitInterfaceImplementations.FirstOrDefault(); var signature = new MethodSignature( methodSymbol.ToDisplayString(format), @@ -217,11 +225,39 @@ protected override MethodProvider[] BuildMethods() [.. methodSymbol.Parameters.Select(p => ConvertToParameterProvider(methodSymbol, p))], ExplicitInterface: explicitInterface?.ContainingType?.GetCSharpType()); - methods.Add(new MethodProvider(signature, MethodBodyStatement.Empty, this)); + var methodProvider = new MethodProvider(signature, MethodBodyStatement.Empty, this); + if (isPartialDeclaration) + { + methodProvider.IsPartialMethod = true; + } + + methods.Add(methodProvider); } return [.. methods]; } + private bool IsPartialMethodDeclaration(IMethodSymbol methodSymbol) + { + // Check each syntax reference for the method + foreach (var syntaxReference in methodSymbol.DeclaringSyntaxReferences) + { + var syntaxNode = syntaxReference.GetSyntax(); + if (syntaxNode is MethodDeclarationSyntax methodSyntax) + { + // Check if it has the partial modifier and no body + 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 74a2511a763..74785ef62c5 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 @@ -319,9 +319,85 @@ private protected virtual FieldProvider[] FilterCustomizedFields(FieldProvider[] private MethodProvider[] BuildMethodsInternal() { var methods = new List(); + var customMethods = CustomCodeView?.Methods ?? []; + + // Build a list of partial method declarations from custom code + var partialMethodDeclarations = customMethods + .Where(m => m.IsPartialMethod) + .ToList(); + foreach (var method in BuildMethods()) { - if (ShouldGenerate(method)) + // If the method is already marked as partial (e.g., by ScmMethodProviderCollection), + // use it as-is without further processing + if (method.IsPartialMethod) + { + methods.Add(method); + continue; + } + + // Check if there's a matching partial method declaration in custom code + var matchingPartialDeclaration = partialMethodDeclarations + .FirstOrDefault(customMethod => IsMatch(customMethod.Signature, method.Signature)); + + if (matchingPartialDeclaration != null) + { + // Generate as a partial method implementation with the custom signature + var customSignature = matchingPartialDeclaration.Signature; + + // Ensure the partial modifier is set + var modifiers = customSignature.Modifiers | MethodSignatureModifiers.Partial; + + // For partial methods, all parameters must be required (no default values) + // Create new parameter list with default values removed + 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(); + + // Create a new signature with the partial modifier and required parameters + var partialSignature = new MethodSignature( + customSignature.Name, + customSignature.Description, + modifiers, + customSignature.ReturnType, + customSignature.ReturnDescription, + requiredParameters, + customSignature.Attributes, + customSignature.GenericArguments, + customSignature.GenericParameterConstraints, + customSignature.ExplicitInterface, + customSignature.NonDocumentComment); + + // Create a new method provider with the custom signature and original implementation + var partialMethod = new MethodProvider( + partialSignature, + method.BodyStatements ?? MethodBodyStatement.Empty, + method.EnclosingType, + method.XmlDocs, + method.Suppressions); + + if (method.BodyExpression != null) + { + partialMethod = new MethodProvider( + partialSignature, + method.BodyExpression, + method.EnclosingType, + method.XmlDocs, + method.Suppressions); + } + + partialMethod.IsPartialMethod = true; + methods.Add(partialMethod); + } + else if (ShouldGenerate(method)) { methods.Add(method); } @@ -588,6 +664,12 @@ private bool ShouldGenerate(MethodProvider method) var customMethods = method.EnclosingType.CustomCodeView?.Methods ?? []; foreach (var customMethod in customMethods) { + // Skip partial method declarations - they will be handled separately in BuildMethodsInternal + if (customMethod.IsPartialMethod) + { + continue; + } + if (IsMatch(customMethod.Signature, method.Signature)) { return false; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index 521f28d7a8d..f8b628356c7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -804,7 +804,8 @@ public IDisposable WriteMethodDeclarationNoScope(MethodSignatureBase methodBase, if (methodBase is MethodSignature method) { - AppendRawIf("virtual ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.Virtual)) + AppendRawIf("partial ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.Partial)) + .AppendRawIf("virtual ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.Virtual)) .AppendRawIf("override ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.Override)) .AppendRawIf("new ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.New)) .AppendRawIf("async ", methodBase.Modifiers.HasFlag(MethodSignatureModifiers.Async)); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs index 51d8d71cd46..ed8f5b5c659 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/OutputLibraryVisitorTests.cs @@ -202,6 +202,21 @@ public void VisitMethodToRenameParameterName() Assert.AreEqual("return newName;\n", testMethod?.BodyStatements!.ToDisplayString()); } + [Test] + public void VisitMethodToUpdateSignature() + { + var parameter = new ParameterProvider("oldName", $"", typeof(string)); + var testMethod = new MethodProvider( + new MethodSignature("TestMethod", $"", MethodSignatureModifiers.Public, null, $"", [parameter]), + Snippet.Return(parameter), new TestTypeProvider()); + + testMethod.Accept(new SignatureCustomizationVisitor()); + + Assert.AreEqual("CustomizedMethod", testMethod.Signature.Name); + Assert.AreEqual("customName", testMethod.Signature.Parameters.First().Name); + Assert.IsTrue(testMethod.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Internal)); + } + private class MethodVisitor : LibraryVisitor { protected internal override MethodProvider? VisitMethod(MethodProvider method) @@ -217,5 +232,27 @@ private class MethodVisitor : LibraryVisitor return base.VisitMethod(method); } } + + private class SignatureCustomizationVisitor : LibraryVisitor + { + protected internal override MethodProvider? VisitMethod(MethodProvider method) + { + if (method.Signature.Name == "TestMethod") + { + var newParams = new List(); + foreach (var param in method.Signature.Parameters) + { + var newParam = new ParameterProvider("customName", param.Description, param.Type); + newParams.Add(newParam); + } + + method.Signature.Update( + name: "CustomizedMethod", + modifiers: MethodSignatureModifiers.Internal, + parameters: newParams); + } + return base.VisitMethod(method); + } + } } }