diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs index e164342ed20..e8496d8987e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/EnumProvider.cs @@ -67,11 +67,33 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputType?.N protected static string RemoveUnderscores(string name) => name.Replace("_", string.Empty); - private protected static string GetBackCompatibleName( + private HashSet? _customMemberNames; + private HashSet CustomMemberNames => _customMemberNames ??= new HashSet( + GetCustomMemberNames(), + StringComparer.OrdinalIgnoreCase); + + private IEnumerable GetCustomMemberNames() + { + if (CustomCodeView is null) + { + return []; + } + + return IsExtensible + ? CustomCodeView.Properties.Select(p => p.Name) + : CustomCodeView.Fields.Select(f => f.Name); + } + + private protected string GetBackCompatibleName( string generatedName, IReadOnlyList generatedNames, IReadOnlyList lastContractNames) { + if (lastContractNames.Count == 0) + { + return generatedName; + } + if (lastContractNames.Any(n => n.Equals(generatedName, StringComparison.OrdinalIgnoreCase))) { return generatedName; @@ -90,9 +112,20 @@ private protected static string GetBackCompatibleName( .Take(2) .ToArray(); - return matchingCurrentNames.Length == 1 && matchingLastContractNames.Length == 1 + var backCompatName = matchingCurrentNames.Length == 1 && matchingLastContractNames.Length == 1 ? matchingLastContractNames[0] : generatedName; + + // If restoring the underscore-preserved name would collide with a member that already + // exists in custom code, keep the generated name so the custom member is preserved + // rather than duplicated or removed. + if (!backCompatName.Equals(generatedName, StringComparison.Ordinal) + && CustomMemberNames.Contains(backCompatName)) + { + return generatedName; + } + + return backCompatName; } protected override bool GetIsEnum() => true; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs index 4fd64c9b4c6..6aa43dc6ac8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/EnumProviderTests.cs @@ -843,6 +843,33 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.AreEqual("Other", enumType.Properties[1].Name); } + // Verifies that when custom code already implements the underscore-named member (e.g. the + // user manually re-added 'Existing_Value' pointing at the generated 'ExistingValue'), the + // underscore back-compat restoration does NOT rename the generated member. Otherwise the + // generated member would collide with (and effectively remove) the custom implementation. + [Test] + public async Task BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores() + { + await MockHelpers.LoadMockGeneratorAsync( + createCSharpTypeCore: (inputType) => typeof(string), + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Custom"), + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync(parameters: "Last")); + + var input = InputFactory.StringEnum("mockInputEnum", [ + ("ExistingValue", "existing"), + ("Other", "other"), + ], isExtensible: true); + + var enumType = EnumProvider.Create(input); + Assert.IsNotNull(enumType.CustomCodeView); + Assert.IsTrue(enumType.CustomCodeView!.Properties.Any(p => p.Name == "Existing_Value")); + + // The generated member keeps its non-underscore name so the custom 'Existing_Value' + // member (which references the generated 'ExistingValue') is preserved. + var content = new TypeProviderWriter(enumType).Write().Content; + Assert.AreEqual(Helpers.GetExpectedFromFile(), content); + } + // Verifies that back-compat does NOT re-introduce enum values that have been suppressed // via [CodeGenSuppress] or that already exist in user-provided custom code. Without // filtering in ProcessTypeForBackCompatibility, the back-compat code would rebuild the diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Custom)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Custom)/MockInputEnum.cs new file mode 100644 index 00000000000..7c7184bd735 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Custom)/MockInputEnum.cs @@ -0,0 +1,9 @@ +#nullable disable + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum + { + public static MockInputEnum Existing_Value { get; } = ExistingValue; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Last)/MockInputEnum.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Last)/MockInputEnum.cs new file mode 100644 index 00000000000..5df3fb5b33a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores(Last)/MockInputEnum.cs @@ -0,0 +1,10 @@ +#nullable disable + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum + { + public static MockInputEnum Existing_Value { get; } + public static MockInputEnum Other { get; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores.cs new file mode 100644 index 00000000000..1a44d0e63a9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/EnumProviders/TestData/EnumProviderTests/BackCompat_ExtensibleEnumCustomCodeTakesPrecedenceOverPreservedUnderscores.cs @@ -0,0 +1,46 @@ +// + +#nullable disable + +using System; +using System.ComponentModel; +using Sample; + +namespace Sample.Models +{ + public readonly partial struct MockInputEnum : global::System.IEquatable + { + private readonly string _value; + private const string ExistingValueValue = "existing"; + private const string OtherValue = "other"; + + public MockInputEnum(string value) + { + global::Sample.Argument.AssertNotNull(value, nameof(value)); + + _value = value; + } + + public static global::Sample.Models.MockInputEnum ExistingValue { get; } = new global::Sample.Models.MockInputEnum(ExistingValueValue); + + public static global::Sample.Models.MockInputEnum Other { get; } = new global::Sample.Models.MockInputEnum(OtherValue); + + public static bool operator ==(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => left.Equals(right); + + public static bool operator !=(global::Sample.Models.MockInputEnum left, global::Sample.Models.MockInputEnum right) => !left.Equals(right); + + public static implicit operator global::Sample.Models.MockInputEnum(string value) => new global::Sample.Models.MockInputEnum(value); + + public static implicit operator global::Sample.Models.MockInputEnum?(string value) => (value == null) ? null : new global::Sample.Models.MockInputEnum(value); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override bool Equals(object obj) => ((obj is global::Sample.Models.MockInputEnum other) && this.Equals(other)); + + public bool Equals(global::Sample.Models.MockInputEnum other) => string.Equals(_value, other._value, global::System.StringComparison.InvariantCultureIgnoreCase); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override int GetHashCode() => (_value != null) ? global::System.StringComparer.InvariantCultureIgnoreCase.GetHashCode(_value) : 0; + + public override string ToString() => _value; + } +}