Repository: MessagePack-CSharp/MessagePack-CSharp

Issue:
MessagePackAssumesFormattable, MessagePackKnownFormatter not resolving MsgPack003 for type with source-generated formatter
#### Bug description

In assembly Utilities, there is a custom generic struct: MyStruct<T>. I cannot change this assembly.

In the assembly I'm working in, Internal, I have a serializable record type MyRecord.

```csharp
[MessagePackObject(AllowPrivate = true)
internal record MyRecord(
    [property: Key(0)] MyStruct<string> Foo);
```

In Internal, I also have a custom formatter for MyStruct<T>. All custom formatters in Internal extend an
abstract base class.

```csharp
internal abstract class CustomFormatter<T> : IMessagePackFormatter<T>
{
  ...
}

internal class MyStructFormatter<T> : CustomFormatter<MyStruct<T>> 
{
    public static readonly IMessagePackFormatter<MyStruct<T>> Instance = new MyStructFormatter<T>();
    ...
}
```

When building Internal, I get an analyzer error:

MyRecord.cs: error MsgPack003: Type must be marked with MessagePackObjectAttribute: MyStruct (https://github.com/MessagePack-CSharp/MessagePack-CSharp/blob/master/doc/analyzers/MsgPack003.md) [Internal.csproj]

I tried adding these assembly level attributes (one at a time) to force the analyzer to recognize the type MyStruct<T> as formattable.

[assembly: MessagePackKnownFormatter(typeof(MyStructFormatter<>))]
[assembly: MessagePackKnownFormatter(typeof(MyStructFormatter<string>))]
[assembly: MessagePackAssumedFormattable(typeof(MyStruct<>))]
[assembly: MessagePackAssumedFormattable(typeof(MyStruct<string>))]

With each one, the result was the same. I would get the same analyzer error.

I can workaround it by putting a MessagePackFormatter attribute on the property in MyRecord.

I would expect that [assembly: MessagePackKnownFormatter(typeof(MyStructFormatter<>))]
would fix the analyzer error.

- Version used: 3.0.238-rc.1
- Runtime: .NET Framework




Hints:
I encontered this issue today. After some investigation, I found it's caused by `GenericTypeParameterInfo.ConstraintTypes`, which doesn't have value equality.

When `ConstraintTypes.Length` is zero (like the code above), this issue can only be reproduced in Visual Studio 2022, but not in unit test project (MessagePack.SourceGenerator.Tests). It seems to be caused by the different platforms running roslyn: Visual Studio 2022 uses .net framework and unit test project uses .net8.

A simple test:
```cs
using System;
using System.Collections.Immutable;
using System.Linq;

namespace ConsoleApp6
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var arr = ImmutableArray<int>.Empty;

            var arr1 = arr.Select(x => x + 1).ToImmutableArray();
            var arr2 = arr.Select(x => x + 1).ToImmutableArray();

            Console.WriteLine(arr1.Equals(arr2));
        }
    }
}
```
It prints `true` in .net8 and prints `false` in net481.


Patch:
diff --git a/src/MessagePack.SourceGenerator/CodeAnalysis/GenericTypeParameterInfo.cs b/src/MessagePack.SourceGenerator/CodeAnalysis/GenericTypeParameterInfo.cs
index 361959503..c35fe171a 100644
--- a/src/MessagePack.SourceGenerator/CodeAnalysis/GenericTypeParameterInfo.cs
+++ b/src/MessagePack.SourceGenerator/CodeAnalysis/GenericTypeParameterInfo.cs
@@ -20,7 +20,7 @@ private GenericTypeParameterInfo(ITypeParameterSymbol typeParameter, ImmutableSt
         this.HasReferenceTypeConstraint = typeParameter.HasReferenceTypeConstraint;
         this.HasValueTypeConstraint = typeParameter.HasValueTypeConstraint;
         this.HasNotNullConstraint = typeParameter.HasNotNullConstraint;
-        this.ConstraintTypes = typeParameter.ConstraintTypes.Select(t => QualifiedTypeName.Create(t, recursionGuard)).ToImmutableArray();
+        this.ConstraintTypes = typeParameter.ConstraintTypes.Select(t => QualifiedTypeName.Create(t, recursionGuard)).ToImmutableEquatableArray();
         this.HasConstructorConstraint = typeParameter.HasConstructorConstraint;
         this.ReferenceTypeConstraintNullableAnnotation = typeParameter.ReferenceTypeConstraintNullableAnnotation;
     }
@@ -49,7 +49,7 @@ public static GenericTypeParameterInfo Create(ITypeParameterSymbol typeParameter
 
     public bool HasNotNullConstraint { get; init; }
 
-    public ImmutableArray<QualifiedTypeName> ConstraintTypes { get; init; }
+    public ImmutableEquatableArray<QualifiedTypeName> ConstraintTypes { get; init; } = ImmutableEquatableArray<QualifiedTypeName>.Empty;
 
     public bool HasConstructorConstraint { get; init; }
 
diff --git a/src/MessagePack.SourceGenerator/Utils/ImmutableEquatableArray.cs b/src/MessagePack.SourceGenerator/Utils/ImmutableEquatableArray.cs
new file mode 100644
index 000000000..590524131
--- /dev/null
+++ b/src/MessagePack.SourceGenerator/Utils/ImmutableEquatableArray.cs
@@ -0,0 +1,96 @@
+// Copyright (c) All contributors. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#pragma warning disable SA1127 // Generic type constraints must be on own line
+#pragma warning disable SA1402 // File may only contain a single type
+#pragma warning disable SA1649 // File name should match first type name
+
+using System.Collections;
+
+namespace MessagePack.SourceGenerator;
+
+/// <summary>
+/// Provides an immutable list implementation which implements sequence equality.
+/// </summary>
+public sealed class ImmutableEquatableArray<T> : IEquatable<ImmutableEquatableArray<T>>, IReadOnlyList<T>
+    where T : IEquatable<T>
+{
+    public static ImmutableEquatableArray<T> Empty { get; } = new ImmutableEquatableArray<T>(Array.Empty<T>());
+
+    private readonly T[] values;
+
+    public T this[int index] => values[index];
+
+    public int Count => values.Length;
+
+    public ImmutableEquatableArray(IEnumerable<T> values)
+        => this.values = values.ToArray();
+
+    public bool Equals(ImmutableEquatableArray<T>? other)
+        => other is not null && ((ReadOnlySpan<T>)values).SequenceEqual(other.values);
+
+    public override bool Equals(object? obj)
+        => obj is ImmutableEquatableArray<T> other && Equals(other);
+
+    public override int GetHashCode()
+    {
+        int hash = 0;
+        foreach (T value in values)
+        {
+            hash = Combine(hash, value is null ? 0 : value.GetHashCode());
+        }
+
+        return hash;
+
+        static int Combine(int h1, int h2)
+        {
+            uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27);
+            return ((int)rol5 + h1) ^ h2;
+        }
+    }
+
+    public Enumerator GetEnumerator() => new Enumerator(values);
+
+    IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)values).GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator() => values.GetEnumerator();
+
+    public struct Enumerator
+    {
+        private readonly T[] values;
+        private int index;
+
+        internal Enumerator(T[] values)
+        {
+            this.values = values;
+            index = -1;
+        }
+
+        public bool MoveNext()
+        {
+            int newIndex = index + 1;
+
+            if ((uint)newIndex < (uint)values.Length)
+            {
+                index = newIndex;
+                return true;
+            }
+
+            return false;
+        }
+
+        public readonly T Current => values[index];
+    }
+}
+
+public static class ImmutableEquatableArray
+{
+    public static ImmutableEquatableArray<T> Empty<T>() where T : IEquatable<T>
+        => ImmutableEquatableArray<T>.Empty;
+
+    public static ImmutableEquatableArray<T> ToImmutableEquatableArray<T>(this IEnumerable<T> values) where T : IEquatable<T>
+        => new(values);
+
+    public static ImmutableEquatableArray<T> Create<T>(params T[] values) where T : IEquatable<T>
+        => values is { Length: > 0 } ? new(values) : ImmutableEquatableArray<T>.Empty;
+}


Test patch:
diff --git a/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs b/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
index 61a152b25..cb5d73b51 100644
--- a/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
+++ b/tests/MessagePack.SourceGenerator.Tests/GenerationTests.cs
@@ -947,4 +947,38 @@ public class A
 
         await VerifyCS.Test.RunDefaultAsync(this.testOutputHelper, testSource);
     }
+
+    [Fact]
+    public async Task CustomFormatterForGenericWithConstraints()
+    {
+        string testSource = /* lang=c#-test */ """
+            using System;
+            using MessagePack;
+            using MessagePack.Formatters;
+
+            [MessagePackObject]
+            public partial class SampleObject
+            {
+                [Key(0)]
+                public MyGenericObject<string> Value;
+            }
+
+            public class MyGenericObject<T> where T : ICloneable { }
+
+            public sealed class MyGenericObjectFormatter<T> : IMessagePackFormatter<MyGenericObject<T>> where T : ICloneable
+            {
+                public void Serialize(ref MessagePackWriter writer, MyGenericObject<T> value, MessagePackSerializerOptions options)
+                {
+                    throw new NotImplementedException();
+                }
+
+                public MyGenericObject<T> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+                {
+                    throw new NotImplementedException();
+                }
+            }
+            """;
+
+        await VerifyCS.Test.RunDefaultAsync(this.testOutputHelper, testSource);
+    }
 }
diff --git a/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/Formatters.MessagePack.GeneratedMessagePackResolver.SampleObjectFormatter.g.cs b/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/Formatters.MessagePack.GeneratedMessagePackResolver.SampleObjectFormatter.g.cs
new file mode 100644
index 000000000..8aef4f642
--- /dev/null
+++ b/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/Formatters.MessagePack.GeneratedMessagePackResolver.SampleObjectFormatter.g.cs
@@ -0,0 +1,58 @@
+﻿// <auto-generated />
+
+#pragma warning disable 618, 612, 414, 168, CS1591, SA1129, SA1309, SA1312, SA1403, SA1649
+
+#pragma warning disable CS8669 // We may leak nullable annotations into generated code.
+
+using MsgPack = global::MessagePack;
+
+namespace MessagePack {
+partial class GeneratedMessagePackResolver {
+
+	internal sealed class SampleObjectFormatter : MsgPack::Formatters.IMessagePackFormatter<global::SampleObject>
+	{
+
+		public void Serialize(ref MsgPack::MessagePackWriter writer, global::SampleObject value, MsgPack::MessagePackSerializerOptions options)
+		{
+			if (value == null)
+			{
+				writer.WriteNil();
+				return;
+			}
+
+			MsgPack::IFormatterResolver formatterResolver = options.Resolver;
+			writer.WriteArrayHeader(1);
+			MsgPack::FormatterResolverExtensions.GetFormatterWithVerify<global::MyGenericObject<string>>(formatterResolver).Serialize(ref writer, value.Value, options);
+		}
+
+		public global::SampleObject Deserialize(ref MsgPack::MessagePackReader reader, MsgPack::MessagePackSerializerOptions options)
+		{
+			if (reader.TryReadNil())
+			{
+				return null;
+			}
+
+			options.Security.DepthStep(ref reader);
+			MsgPack::IFormatterResolver formatterResolver = options.Resolver;
+			var length = reader.ReadArrayHeader();
+			var ____result = new global::SampleObject();
+
+			for (int i = 0; i < length; i++)
+			{
+				switch (i)
+				{
+					case 0:
+						____result.Value = MsgPack::FormatterResolverExtensions.GetFormatterWithVerify<global::MyGenericObject<string>>(formatterResolver).Deserialize(ref reader, options);
+						break;
+					default:
+						reader.Skip();
+						break;
+				}
+			}
+
+			reader.Depth--;
+			return ____result;
+		}
+	}
+}
+}
diff --git a/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/MessagePack.GeneratedMessagePackResolver.g.cs b/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/MessagePack.GeneratedMessagePackResolver.g.cs
new file mode 100644
index 000000000..3a33d483a
--- /dev/null
+++ b/tests/MessagePack.SourceGenerator.Tests/Resources/CustomFormatterForGenericWithConstraints/MessagePack.GeneratedMessagePackResolver.g.cs
@@ -0,0 +1,75 @@
+﻿// <auto-generated />
+
+#pragma warning disable 618, 612, 414, 168, CS1591, SA1129, SA1309, SA1312, SA1403, SA1649
+
+using MsgPack = global::MessagePack;
+
+[assembly: MsgPack::Internal.GeneratedAssemblyMessagePackResolverAttribute(typeof(MessagePack.GeneratedMessagePackResolver), 3, 0)]
+
+namespace MessagePack {
+
+/// <summary>A MessagePack resolver that uses generated formatters for types in this assembly.</summary>
+partial class GeneratedMessagePackResolver : MsgPack::IFormatterResolver
+{
+	/// <summary>An instance of this resolver that only returns formatters specifically generated for types in this assembly.</summary>
+	public static readonly MsgPack::IFormatterResolver Instance = new GeneratedMessagePackResolver();
+
+	private GeneratedMessagePackResolver()
+	{
+	}
+
+	public MsgPack::Formatters.IMessagePackFormatter<T> GetFormatter<T>()
+	{
+		return FormatterCache<T>.Formatter;
+	}
+
+	private static class FormatterCache<T>
+	{
+		internal static readonly MsgPack::Formatters.IMessagePackFormatter<T> Formatter;
+
+		static FormatterCache()
+		{
+			var f = GeneratedMessagePackResolverGetFormatterHelper.GetFormatter(typeof(T));
+			if (f != null)
+			{
+				Formatter = (MsgPack::Formatters.IMessagePackFormatter<T>)f;
+			}
+		}
+	}
+
+	private static class GeneratedMessagePackResolverGetFormatterHelper
+	{
+		private static readonly global::System.Collections.Generic.Dictionary<global::System.Type, int> closedTypeLookup = new global::System.Collections.Generic.Dictionary<global::System.Type, int>(1)
+		{
+			{ typeof(global::SampleObject), 0 },
+		};
+		private static readonly global::System.Collections.Generic.Dictionary<global::System.Type, int> openTypeLookup = new global::System.Collections.Generic.Dictionary<global::System.Type, int>(1)
+		{
+			{ typeof(global::MyGenericObject<>), 0 },
+		};
+
+		internal static object GetFormatter(global::System.Type t)
+		{
+			if (closedTypeLookup.TryGetValue(t, out int closedKey))
+			{
+				switch (closedKey)
+				{
+					case 0: return new global::MessagePack.GeneratedMessagePackResolver.SampleObjectFormatter();
+					default: return null; // unreachable
+				};
+			}
+			if (t.IsGenericType && openTypeLookup.TryGetValue(t.GetGenericTypeDefinition(), out int openKey))
+			{
+				switch (openKey)
+				{
+					case 0: return global::System.Activator.CreateInstance(typeof(global::MyGenericObjectFormatter<>).MakeGenericType(t.GenericTypeArguments));
+					default: return null; // unreachable
+				};
+			}
+
+			return null;
+		}
+	}
+}
+
+}