Skip to content

Commit 07efc2a

Browse files
[http-client-csharp] Dedup operators between generated and customization partials (#10639)
Customization partials that declare `==`, `!=`, or `implicit`/`explicit` operators on a type whose generated partial also emits those operators (e.g. extensible enums) trigger CS0111 because the dedup path in `MethodSignatureBase.SignatureComparer` never matches operator methods. Root cause: the comparer's early `Name` equality check fails for operators because the two sides use different naming conventions: | Operator | Generated (`ExtensibleEnumProvider` / `MrwSerializationTypeDefinition`) | Customization (`NamedTypeSymbolProvider` via Roslyn `ToDisplayString`) | | --- | --- | --- | | `==`, `!=` | `"=="`, `"!="` | `"operator =="`, `"operator !="` | | `implicit operator T(...)` | `string.Empty` | `"T"` (return type name) | | `explicit operator T(...)` | `"T"` | `"T"` | Only the explicit-operator case happens to align, which is why MRW-serialization dedup tests pass while extensible-enum operators slip through. ### Changes - **`MethodSignatureBase.SignatureComparer.Equals`** — when both signatures carry the `Operator` modifier: - User-defined operators (`==`, `!=`, `+`, …): normalize the name by stripping a leading `"operator "` prefix before comparing, so `"operator =="` matches `"=="` while `==` and `!=` remain distinguishable. - Conversion operators (`implicit`/`explicit`): skip the `Name` comparison entirely. Modifiers + return type + parameter types fully identify a conversion operator (C# disallows duplicates). - Existing return-type and explicit-vs-implicit modifier checks are preserved. - **`MethodSignatureComparerTests`** — new unit tests covering: `==` generated/customization name normalization, `==` vs `!=` not equal, implicit-cast `string.Empty` matching customization return-type name, and implicit vs explicit not equal. - **`NamedTypeSymbolProviderTests.ValidateOperatorSignaturesMatchGenerated`** — end-to-end test that uses the existing TestData/`Helpers.GetCompilationFromDirectoryAsync()` infrastructure to load a `WithOperators` customization partial, parse its operator signatures via `NamedTypeSymbolProvider`, and assert they match generated-side signatures (`==`, `!=`, `implicit operator T(string)`) through `MethodSignatureBase.SignatureComparer`. - **`TypeProviderTests.CanonicalViewDedupesCustomOperators`** — end-to-end test that builds a generated `TypeProvider` with `==`, `!=`, and `implicit` operator `MethodProvider`s, loads a matching customization partial via TestData, and asserts that `CanonicalView.Methods` contains exactly the three operators all sourced from `CustomCodeView` (i.e., the generated copies are filtered out). ### Example ```csharp // Generated partial (from ExtensibleEnumProvider) public readonly partial struct MyEnum { public static bool operator ==(MyEnum left, MyEnum right) => left.Equals(right); public static implicit operator MyEnum(string value) => new MyEnum(value); } // Customization partial — previously triggered CS0111 on both operators; // now correctly suppresses the duplicates from the generated partial. public readonly partial struct MyEnum : IEquatable<MyEnum> { public static bool operator ==(MyEnum left, MyEnum right) => left.Equals(right); public static implicit operator MyEnum(string value) => new MyEnum(value); } ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com>
1 parent 99740ca commit 07efc2a

6 files changed

Lines changed: 254 additions & 8 deletions

File tree

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/MethodSignatureBase.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,21 +103,22 @@ public bool Equals(MethodSignatureBase? x, MethodSignatureBase? y)
103103
return false;
104104
}
105105

106-
if (x.Parameters.Count != y.Parameters.Count || GetFullMethodName(x) != GetFullMethodName(y))
106+
if (x.Parameters.Count != y.Parameters.Count)
107+
{
108+
return false;
109+
}
110+
111+
bool xIsOperator = x.Modifiers.HasFlag(MethodSignatureModifiers.Operator);
112+
bool yIsOperator = y.Modifiers.HasFlag(MethodSignatureModifiers.Operator);
113+
if (xIsOperator != yIsOperator)
107114
{
108115
return false;
109116
}
110117

111118
// For operators, we need to also check the return type and operator type (explicit vs implicit)
112119
// since operators can have the same "name" (the target type) but different signatures
113-
if (x.Modifiers.HasFlag(MethodSignatureModifiers.Operator))
120+
if (xIsOperator)
114121
{
115-
// Check if both are operators and of the same type (explicit or implicit)
116-
if (!y.Modifiers.HasFlag(MethodSignatureModifiers.Operator))
117-
{
118-
return false;
119-
}
120-
121122
// Check explicit vs implicit - both flags must match
122123
bool xIsExplicit = x.Modifiers.HasFlag(MethodSignatureModifiers.Explicit);
123124
bool yIsExplicit = y.Modifiers.HasFlag(MethodSignatureModifiers.Explicit);
@@ -140,6 +141,17 @@ public bool Equals(MethodSignatureBase? x, MethodSignatureBase? y)
140141
{
141142
return false;
142143
}
144+
145+
// Compare user-defined operators by symbol; conversion operators are fully identified by modifiers/return type/params.
146+
if (!xIsImplicit && !xIsExplicit
147+
&& NormalizeOperatorName(GetFullMethodName(x)) != NormalizeOperatorName(GetFullMethodName(y)))
148+
{
149+
return false;
150+
}
151+
}
152+
else if (GetFullMethodName(x) != GetFullMethodName(y))
153+
{
154+
return false;
143155
}
144156

145157
for (int i = 0; i < x.Parameters.Count; i++)
@@ -167,6 +179,15 @@ private static string GetFullMethodName(MethodSignatureBase method)
167179

168180
return method.Name;
169181
}
182+
183+
private static string NormalizeOperatorName(string name)
184+
{
185+
// Strip Roslyn's "operator " prefix so "operator ==" matches "==".
186+
const string operatorPrefix = "operator ";
187+
return name.StartsWith(operatorPrefix, StringComparison.Ordinal)
188+
? name.Substring(operatorPrefix.Length)
189+
: name;
190+
}
170191
}
171192
}
172193
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.TypeSpec.Generator.Primitives;
5+
using Microsoft.TypeSpec.Generator.Providers;
6+
using NUnit.Framework;
7+
8+
namespace Microsoft.TypeSpec.Generator.Tests.Primitives
9+
{
10+
internal class MethodSignatureComparerTests
11+
{
12+
public MethodSignatureComparerTests()
13+
{
14+
MockHelpers.LoadMockGenerator();
15+
}
16+
17+
// Validates that a generated `==` operator (Name = "==") matches a customization
18+
// partial declaration where the Name comes from Roslyn's SymbolDisplay
19+
// (Name = "operator ==").
20+
[Test]
21+
public void EqualityOperator_GeneratedAndCustomization_MatchByNormalizedName()
22+
{
23+
var enumType = new CSharpType(typeof(int));
24+
var leftParam = new ParameterProvider("left", $"left", enumType);
25+
var rightParam = new ParameterProvider("right", $"right", enumType);
26+
var modifiers = MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Operator;
27+
28+
// What ExtensibleEnumProvider produces for the generated `==` operator.
29+
var generated = new MethodSignature("==", null, modifiers, typeof(bool), null, [leftParam, rightParam]);
30+
31+
// What NamedTypeSymbolProvider produces for the customization partial: Roslyn's
32+
// SymbolDisplay returns "operator ==" for an op_Equality method.
33+
var customization = new MethodSignature("operator ==", null, modifiers, typeof(bool), null, [leftParam, rightParam]);
34+
35+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(generated, customization));
36+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(customization, generated));
37+
}
38+
39+
// Validates that `==` and `!=` operators with the same signature shape are NOT
40+
// considered equal (regression guard for the operator-symbol differentiation).
41+
[Test]
42+
public void EqualityAndInequalityOperators_AreNotEqual()
43+
{
44+
var enumType = new CSharpType(typeof(int));
45+
var leftParam = new ParameterProvider("left", $"left", enumType);
46+
var rightParam = new ParameterProvider("right", $"right", enumType);
47+
var modifiers = MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Operator;
48+
49+
var equality = new MethodSignature("==", null, modifiers, typeof(bool), null, [leftParam, rightParam]);
50+
var inequality = new MethodSignature("!=", null, modifiers, typeof(bool), null, [leftParam, rightParam]);
51+
52+
Assert.IsFalse(MethodSignatureBase.SignatureComparer.Equals(equality, inequality));
53+
}
54+
55+
// Validates that an implicit conversion operator from generated code (Name = "")
56+
// matches a customization partial (Name = return type name) when modifiers,
57+
// return type, and parameter types agree.
58+
[Test]
59+
public void ImplicitConversionOperator_GeneratedEmptyName_MatchesCustomizationReturnTypeName()
60+
{
61+
var enumType = new CSharpType(typeof(int));
62+
var valueParam = new ParameterProvider("value", $"value", typeof(string));
63+
var modifiers = MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Implicit | MethodSignatureModifiers.Operator;
64+
65+
// ExtensibleEnumProvider generates the implicit operator with Name = string.Empty.
66+
var generated = new MethodSignature(string.Empty, null, modifiers, enumType, null, [valueParam]);
67+
68+
// NamedTypeSymbolProvider produces the customization partial with Name = return type name.
69+
var customization = new MethodSignature(enumType.Name, null, modifiers, enumType, null, [valueParam]);
70+
71+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(generated, customization));
72+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(customization, generated));
73+
}
74+
75+
// Implicit and explicit conversion operators with otherwise identical signatures must NOT be equal.
76+
[Test]
77+
public void ImplicitAndExplicitConversionOperators_AreNotEqual()
78+
{
79+
var enumType = new CSharpType(typeof(int));
80+
var valueParam = new ParameterProvider("value", $"value", typeof(string));
81+
82+
var implicitOp = new MethodSignature(
83+
string.Empty,
84+
null,
85+
MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Implicit | MethodSignatureModifiers.Operator,
86+
enumType,
87+
null,
88+
[valueParam]);
89+
90+
var explicitOp = new MethodSignature(
91+
enumType.Name,
92+
null,
93+
MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Operator,
94+
enumType,
95+
null,
96+
[valueParam]);
97+
98+
Assert.IsFalse(MethodSignatureBase.SignatureComparer.Equals(implicitOp, explicitOp));
99+
}
100+
}
101+
}
102+

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/NamedTypeSymbolProviders/NamedTypeSymbolProviderTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,54 @@ public async Task ValidatePartialMethodWithBodyIsNotDetectedAsPartialDeclaration
370370
Assert.IsFalse(doIt.IsPartialMethod, "Partial methods with bodies should not be treated as customization signals.");
371371
}
372372

373+
// Operator signatures parsed from a customization partial must compare equal to the corresponding generated signatures.
374+
[Test]
375+
public async Task ValidateOperatorSignaturesMatchGenerated()
376+
{
377+
var mockGenerator = await MockHelpers.LoadMockGeneratorAsync(
378+
compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
379+
var compilation = mockGenerator.Object.SourceInputModel.Customization;
380+
Assert.IsNotNull(compilation);
381+
382+
var symbol = CompilationHelper.GetSymbol(compilation!.Assembly.Modules.First().GlobalNamespace, "WithOperators")!;
383+
var provider = new NamedTypeSymbolProvider(symbol, compilation);
384+
var typeFromCustomization = provider.Type;
385+
386+
var leftParam = new ParameterProvider("left", $"left", typeFromCustomization);
387+
var rightParam = new ParameterProvider("right", $"right", typeFromCustomization);
388+
var valueParam = new ParameterProvider("value", $"value", typeof(string));
389+
var operatorModifiers = MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Operator;
390+
var implicitOperatorModifiers = operatorModifiers | MethodSignatureModifiers.Implicit;
391+
392+
// Mirror the signatures emitted by generated providers (e.g. ExtensibleEnumProvider).
393+
var generatedEquality = new MethodSignature("==", null, operatorModifiers, typeof(bool), null, [leftParam, rightParam]);
394+
var generatedInequality = new MethodSignature("!=", null, operatorModifiers, typeof(bool), null, [leftParam, rightParam]);
395+
var generatedImplicit = new MethodSignature(string.Empty, null, implicitOperatorModifiers, typeFromCustomization, null, [valueParam]);
396+
397+
var customEquality = provider.Methods.Single(m =>
398+
m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Operator)
399+
&& !m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Implicit)
400+
&& m.Signature.Name.EndsWith("=="));
401+
var customInequality = provider.Methods.Single(m =>
402+
m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Operator)
403+
&& !m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Implicit)
404+
&& m.Signature.Name.EndsWith("!="));
405+
var customImplicit = provider.Methods.Single(m =>
406+
m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Implicit)
407+
&& m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Operator));
408+
409+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(generatedEquality, customEquality.Signature),
410+
"Generated `==` operator should match the `==` operator parsed from the customization partial.");
411+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(generatedInequality, customInequality.Signature),
412+
"Generated `!=` operator should match the `!=` operator parsed from the customization partial.");
413+
Assert.IsTrue(MethodSignatureBase.SignatureComparer.Equals(generatedImplicit, customImplicit.Signature),
414+
"Generated implicit conversion operator should match the implicit operator parsed from the customization partial.");
415+
416+
// Sanity check: `==` and `!=` parsed from customization must remain distinguishable.
417+
Assert.IsFalse(MethodSignatureBase.SignatureComparer.Equals(customEquality.Signature, customInequality.Signature),
418+
"`==` and `!=` operators must not compare as equal even though their parameter shapes match.");
419+
}
420+
373421
[Test]
374422
public void ValidateMethods()
375423
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Sample
2+
{
3+
public readonly partial struct WithOperators
4+
{
5+
private readonly string _value;
6+
7+
public WithOperators(string value)
8+
{
9+
_value = value;
10+
}
11+
12+
public static bool operator ==(WithOperators left, WithOperators right) => left.Equals(right);
13+
14+
public static bool operator !=(WithOperators left, WithOperators right) => !left.Equals(right);
15+
16+
public static implicit operator WithOperators(string value) => new WithOperators(value);
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#nullable disable
2+
3+
using System;
4+
5+
namespace Test;
6+
7+
public partial class CustomOperatorType
8+
{
9+
public static bool operator ==(CustomOperatorType left, CustomOperatorType right) => ReferenceEquals(left, right);
10+
11+
public static bool operator !=(CustomOperatorType left, CustomOperatorType right) => !ReferenceEquals(left, right);
12+
13+
public static implicit operator CustomOperatorType(string value) => new CustomOperatorType();
14+
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/TypeProviderTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,5 +490,48 @@ public void TestSpecViewDelegatesCorrectly()
490490
Assert.AreEqual("TestMethod", specView.Methods[0].Signature.Name);
491491
Assert.AreEqual("TestType", specView.Name);
492492
}
493+
494+
// Validates that a generated type whose customization partial declares the same operators
495+
// (==, !=, implicit) ends up with only the custom operators in its CanonicalView.
496+
[Test]
497+
public async Task CanonicalViewDedupesCustomOperators()
498+
{
499+
await MockHelpers.LoadMockGeneratorAsync(compilation: async () => await Helpers.GetCompilationFromDirectoryAsync());
500+
501+
var typeProvider = new TestTypeProvider(name: "CustomOperatorType");
502+
var leftParam = new ParameterProvider("left", $"", typeProvider.Type);
503+
var rightParam = new ParameterProvider("right", $"", typeProvider.Type);
504+
var valueParam = new ParameterProvider("value", $"", typeof(string));
505+
var operatorModifiers = MethodSignatureModifiers.Public | MethodSignatureModifiers.Static | MethodSignatureModifiers.Operator;
506+
var implicitOperatorModifiers = operatorModifiers | MethodSignatureModifiers.Implicit;
507+
508+
var equality = new MethodProvider(
509+
new MethodSignature("==", $"", operatorModifiers, typeof(bool), $"", [leftParam, rightParam]),
510+
Snippet.Throw(Snippet.Null), typeProvider);
511+
var inequality = new MethodProvider(
512+
new MethodSignature("!=", $"", operatorModifiers, typeof(bool), $"", [leftParam, rightParam]),
513+
Snippet.Throw(Snippet.Null), typeProvider);
514+
var implicitCast = new MethodProvider(
515+
new MethodSignature(string.Empty, $"", implicitOperatorModifiers, typeProvider.Type, $"", [valueParam]),
516+
Snippet.Throw(Snippet.Null), typeProvider);
517+
518+
typeProvider = new TestTypeProvider(name: "CustomOperatorType", methods: [equality, inequality, implicitCast]);
519+
520+
Assert.IsNotNull(typeProvider.CustomCodeView);
521+
522+
var operatorMethods = typeProvider.CanonicalView.Methods
523+
.Where(m => m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Operator))
524+
.ToList();
525+
526+
// CanonicalView should only contain the 3 customized operators (no duplicates from generated).
527+
Assert.AreEqual(3, operatorMethods.Count);
528+
Assert.IsTrue(operatorMethods.All(m => m.EnclosingType == typeProvider.CustomCodeView),
529+
"Operator methods in CanonicalView should come from the custom code view, not the generated provider.");
530+
Assert.IsTrue(operatorMethods.Any(m =>
531+
m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Implicit)));
532+
Assert.AreEqual(2, operatorMethods.Count(m =>
533+
!m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Implicit)
534+
&& !m.Signature.Modifiers.HasFlag(MethodSignatureModifiers.Explicit)));
535+
}
493536
}
494537
}

0 commit comments

Comments
 (0)