Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,31 @@ public async Task CanReplaceMethod()
Assert.AreEqual(1, customMethods.Count);
Assert.AreEqual("ToSerialString", customMethods[0].Signature.Name);
}

[Test]
public async Task BackCompat_FixedEnumSerializationUsesPreservedUnderscores()
{
var inputEnum = InputFactory.Int32Enum(
"mockInputEnum",
[
("ExistingValue", 0),
("Other", 1)
]);
await MockHelpers.LoadMockGeneratorAsync(
inputEnums: () => [inputEnum],
lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync());

var enumProvider = ScmCodeModelGenerator.Instance.TypeFactory.CreateEnum(inputEnum);
Assert.IsNotNull(enumProvider);

var serializationProvider = enumProvider!.SerializationProviders.Single();
_ = serializationProvider.Methods;
enumProvider.EnsureBuilt();
enumProvider.ProcessTypeForBackCompatibility();

var file = new TypeProviderWriter(serializationProvider).Write();
StringAssert.Contains("MockInputEnum.Existing_Value", file.Content);
StringAssert.DoesNotContain("MockInputEnum.ExistingValue", file.Content);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#nullable disable

namespace Sample.Models
{
public enum MockInputEnum
{
Existing_Value = 0,
Other = 1,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ internal static async Task AddPackageReferencesFromProject()
/// </summary>
internal static ApiCompatBaseline LoadApiCompatBaseline()
{
var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace;
var packageName = CodeModelGenerator.Instance.Configuration.PackageName;
var directory = new DirectoryInfo(CodeModelGenerator.Instance.Configuration.ProjectDirectory);

while (directory != null)
Expand All @@ -395,7 +395,7 @@ internal static ApiCompatBaseline LoadApiCompatBaseline()

internal static async Task<Compilation?> LoadBaselineContract()
{
var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace;
var packageName = CodeModelGenerator.Instance.Configuration.PackageName;
string projectFilePath = Path.GetFullPath(Path.Combine(CodeModelGenerator.Instance.Configuration.ProjectDirectory, $"{packageName}.csproj"));

if (!File.Exists(projectFilePath))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.TypeSpec.Generator.Input;
using Microsoft.TypeSpec.Generator.Input.Extensions;
using Microsoft.TypeSpec.Generator.Primitives;
Expand Down Expand Up @@ -63,6 +65,36 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputType?.N
CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace : // we default to this model namespace when the namespace is empty
CodeModelGenerator.Instance.TypeFactory.GetCleanNameSpace(_inputType.Namespace);

protected static string RemoveUnderscores(string name) => name.Replace("_", string.Empty);

private protected static string GetBackCompatibleName(
string generatedName,
IReadOnlyList<string> generatedNames,
IReadOnlyList<string> lastContractNames)
{
if (lastContractNames.Any(n => n.Equals(generatedName, StringComparison.OrdinalIgnoreCase)))
{
return generatedName;
}

var normalizedName = RemoveUnderscores(generatedName);
// A normalized match ignores underscores and casing. Preserve the last-contract name only
// when exactly one current member and one last-contract member have the same normalized name;
// multiple matches are ambiguous. Only two matches are needed to distinguish those cases.
var matchingCurrentNames = generatedNames
.Where(n => RemoveUnderscores(n).Equals(normalizedName, StringComparison.OrdinalIgnoreCase))
.Take(2)
.ToArray();
var matchingLastContractNames = lastContractNames
.Where(n => RemoveUnderscores(n).Equals(normalizedName, StringComparison.OrdinalIgnoreCase))
.Take(2)
.ToArray();

return matchingCurrentNames.Length == 1 && matchingLastContractNames.Length == 1
? matchingLastContractNames[0]
: generatedName;
}

protected override bool GetIsEnum() => true;
protected override CSharpType BuildEnumUnderlyingType() => CodeModelGenerator.Instance.TypeFactory.CreateCSharpType(_inputType!.ValueType) ?? throw new InvalidOperationException($"Failed to create CSharpType for {_inputType.ValueType}");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ internal ExtensibleEnumProvider(InputEnumType input, TypeProvider? declaringType

protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
{
var generatedNames = _allowedValues
.Select(v => v.IsExactName ? v.Name : v.Name.ToIdentifierName())
.ToArray();
var lastContractNames = LastContractView?.Properties.Select(p => p.Name).ToArray() ?? [];
var values = new EnumTypeMember[_allowedValues.Count];

for (int i = 0; i < _allowedValues.Count; i++)
Expand All @@ -56,7 +60,7 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
// build the field
var modifiers = FieldModifiers.Private | FieldModifiers.Const;
// the fields for extensible enums are private and const, storing the underlying values, therefore we need to append the word `Value` to the name
var valueName = inputValue.IsExactName ? inputValue.Name : inputValue.Name.ToIdentifierName();
var valueName = GetBackCompatibleName(generatedNames[i], generatedNames, lastContractNames);
var name = $"{valueName}Value";
// for initializationValue, if the enum is extensible, we always need it
var initializationValue = Literal(inputValue.Value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ protected override TypeProvider[] BuildSerializationProviders()
protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
{
var customMembers = new HashSet<FieldProvider>(CustomCodeView?.Fields ?? []);
var generatedNames = AllowedValues
.Select(v => v.IsExactName ? v.Name : v.Name.ToIdentifierName())
.ToArray();
var lastContractFields = LastContractView?.Fields ?? [];
var lastContractNames = lastContractFields.Select(f => f.Name).ToArray();

var values = new EnumTypeMember[AllowedValues.Count];

Expand All @@ -81,13 +86,13 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
var inputValue = AllowedValues[i];
var modifiers = FieldModifiers.Public | FieldModifiers.Static;
// the fields for fixed enums are just its members (we use fields to represent the values in a system `enum` type), we just use the name for this field
var name = inputValue.IsExactName ? inputValue.Name : inputValue.Name.ToIdentifierName();
var name = GetBackCompatibleName(generatedNames[i], generatedNames, lastContractNames);

// check if the enum member was renamed in custom code
string? customMemberName = null;
foreach (var customMember in customMembers)
{
if (customMember.OriginalName == name)
if (customMember.OriginalName == generatedNames[i])
{
customMemberName = customMember.Name;
}
Expand Down Expand Up @@ -124,10 +129,12 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()

var currentLookup = currentValues.ToDictionary(v => v.Name, StringComparer.OrdinalIgnoreCase);
var allMembers = new List<EnumTypeMember>(currentValues.Count);
var processedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customMemberLastContractNames = GetCustomMemberLastContractNames(lastContractFields);

foreach (var field in lastContractFields)
{
if (currentLookup.TryGetValue(field.Name, out var existingMember))
if (currentLookup.TryGetValue(field.Name, out var existingMember) && processedNames.Add(existingMember.Name))
{
// By default, preserve the last contract's explicit value for integer enums so
// members keep their exact values. If the baseline accepts a value change for this
Expand All @@ -151,6 +158,10 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
initializationValue);
allMembers.Add(new EnumTypeMember(existingMember.Name, updatedField, memberValue));
}
else if (customMemberLastContractNames.Contains(field.Name))
{
continue;
}
else if (CodeModelGenerator.Instance.SourceInputModel?.ApiCompatBaseline.IsMemberSuppressed(Type.FullyQualifiedName, field.Name, 0) == true)
{
CodeModelGenerator.Instance.Emitter.Debug(
Expand All @@ -167,7 +178,13 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
}

// Then, add new members that weren't in the last contract (in their original input order).
AppendMembersNotInLastContract(currentValues, lastContractFields, allMembers);
foreach (var current in currentValues)
{
if (!processedNames.Contains(current.Name))
{
allMembers.Add(current);
}
}

// Report a reordering only when the relative order of members present in BOTH the
// current values and the resulting set was actually altered.
Expand All @@ -181,6 +198,31 @@ protected override IReadOnlyList<EnumTypeMember> BuildEnumValues()
return allMembers;
}

private HashSet<string> GetCustomMemberLastContractNames(IReadOnlyList<FieldProvider> lastContractFields)
{
var customOriginalNames = new HashSet<string>(
CustomCodeView?.Fields
.Where(f => f.OriginalName != null)
.Select(f => f.OriginalName!) ?? [],
StringComparer.Ordinal);
var generatedNames = AllowedValues
.Select(v => v.IsExactName ? v.Name : v.Name.ToIdentifierName())
.ToArray();
var lastContractNames = lastContractFields.Select(f => f.Name).ToArray();
var customMemberLastContractNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

for (int i = 0; i < generatedNames.Length; i++)
{
if (customOriginalNames.Contains(generatedNames[i]))
{
customMemberLastContractNames.Add(
GetBackCompatibleName(generatedNames[i], generatedNames, lastContractNames));
}
}

return customMemberLastContractNames;
}

private bool TryResurrectRemovedMember(FieldProvider lastContractField, [NotNullWhen(true)] out EnumTypeMember? member)
{
member = null;
Expand Down Expand Up @@ -225,19 +267,22 @@ private static bool SharedMemberOrderChanged(
var currentNames = new HashSet<string>(currentValues.Count, StringComparer.Ordinal);
foreach (var member in currentValues)
{
currentNames.Add(member.Name);
currentNames.Add(RemoveUnderscores(member.Name));
}

var index = 0;
foreach (var member in result)
{
if (!currentNames.Contains(member.Name))
if (!currentNames.Contains(RemoveUnderscores(member.Name)))
{
continue;
}

if (index >= currentValues.Count
|| !string.Equals(member.Name, currentValues[index].Name, StringComparison.Ordinal))
|| !string.Equals(
RemoveUnderscores(member.Name),
RemoveUnderscores(currentValues[index].Name),
StringComparison.OrdinalIgnoreCase))
{
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ await MockHelpers.LoadMockGeneratorAsync(
Assert.NotNull(fooMethod, "Foo method should be found in the SimpleType");
}

[TestCase(Category = EvaluatedFrameworkTestCategory)]
public async Task TestLoadBaselineContractUsesPackageNameWhenNamespaceDiffers()
{
const string ns = "Service.Namespace";
const string packageName = "Service.Package";
var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache");
CreateTestAssemblyAndProjectFile(
nugetCacheDir,
"TestNamespace.csproj",
packageName,
ns,
$"{packageName}.csproj");

await MockHelpers.LoadMockGeneratorAsync(
inputNamespaceName: ns,
outputPath: _projectDir,
configuration: $"{{\"package-name\": \"{packageName}\"}}");

var compilation = await GeneratedCodeWorkspace.LoadBaselineContract();

Assert.NotNull(compilation, "Compilation should not be null");
Assert.NotNull(compilation!.GetTypeByMetadataName($"{ns}.SimpleType"));
}

[Test]
public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj()
{
Expand Down Expand Up @@ -336,11 +360,18 @@ public class Placeholder {{ }}
return dllPath;
}

private void CreateTestAssemblyAndProjectFile(string nugetCacheDir, string csProjectFileName)
private void CreateTestAssemblyAndProjectFile(
string nugetCacheDir,
string csProjectFileName,
string? packageName = null,
string? namespaceName = null,
string? destinationProjectFileName = null)
{
var ns = csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue")
var ns = namespaceName ?? (csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue")
? "TestNamespaceUnevaluatedFrameworkValue"
: "TestNamespace";
: "TestNamespace");
packageName ??= ns;
destinationProjectFileName ??= csProjectFileName;

var syntaxTree = CSharpSyntaxTree.ParseText($@"
namespace {ns}
Expand Down Expand Up @@ -377,19 +408,19 @@ public void Foo(string p1) {{ }}
Assert.Fail("Failed to open test project file.");
}

var csProjDestination = Path.Combine(_projectDir!, "src", csProjectFileName);
var csProjDestination = Path.Combine(_projectDir!, "src", destinationProjectFileName);
projectRoot!.Save(csProjDestination);

var compilation = CSharpCompilation.Create(
ns,
packageName,
[syntaxTree],
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

var nugetPackageDir = Path.Combine(nugetCacheDir, ns.ToLowerInvariant(), version, "lib", "netstandard2.0");
var nugetPackageDir = Path.Combine(nugetCacheDir, packageName.ToLowerInvariant(), version, "lib", "netstandard2.0");
Directory.CreateDirectory(nugetPackageDir);

var dllPath = Path.Combine(nugetPackageDir, $"{ns}.dll");
var dllPath = Path.Combine(nugetPackageDir, $"{packageName}.dll");
var emitResult = compilation.Emit(dllPath);
Assert.IsTrue(emitResult.Success, $"Failed to emit test assembly: ${string.Join(", ", emitResult.Diagnostics)}");
}
Expand Down
Loading
Loading