From 450a14a12618352ef5af481b8942f01270dadb2d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 12 Mar 2026 18:50:22 +0200 Subject: [PATCH] Add prototype support for C# union types, closed enums, and closed hierarchies This commit introduces experimental support for three upcoming C# language features: 1. **C# Union Types** - Maps to existing IUnionTypeShape abstraction via new UnionKind enum (ClassHierarchy, FSharpUnion, CSharpUnion). Uses DelegateMarshaler for marshal/unmarshal between unrelated case types and union types. Detection via [Union] attribute + IUnion interface by metadata name. 2. **Closed Enums** - Adds IsClosed property to IEnumTypeShape, detected via [Closed] attribute by metadata name. Supported in both reflection and source generator providers. 3. **Closed Hierarchies** - [ClosedSubtype] attributes are always honored for union detection. Assembly scanning fallback requires InferDerivedTypes opt-in via TypeShapeAttribute property. Key design decisions: - No breaking changes to public interfaces (InternalImplementationsOnly) - Detection by metadata name avoids shipping BCL-conflicting types - IUnionCaseShape has no TUnionCase:TUnion constraint at interface level, enabling C# union support without new abstractions - Source gen uses string UnionKindName to avoid accessibility issues - Example JSON serializer extended with structural matching for C# unions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Converters/JsonCSharpUnionConverter.cs | 67 ++++++ .../Converters/JsonObjectConverter.cs | 5 + .../Converters/JsonUnionCaseConverter.cs | 85 ++++++- .../JsonSerializer/JsonSerializer.Builder.cs | 6 +- src/PolyType.Roslyn/Model/EnumDataModel.cs | 5 + .../TypeDataModelGenerator.Enum.cs | 3 + .../Model/EnumShapeModel.cs | 2 + .../Model/UnionShapeModel.cs | 5 + .../Parser/Parser.ModelMapper.cs | 9 +- src/PolyType.SourceGenerator/Parser/Parser.cs | 13 +- .../PolyType.SourceGenerator.csproj | 1 + .../PolyTypeKnownSymbols.cs | 12 + .../SourceFormatter/SourceFormatter.Enum.cs | 1 + .../SourceFormatter.FSharpUnion.cs | 1 + .../SourceFormatter/SourceFormatter.Union.cs | 1 + .../MockRuntimeAttributes.cs | 45 ++++ src/PolyType.TestCases/TestTypes.cs | 115 ++++++++- src/PolyType/Abstractions/IEnumTypeShape.cs | 8 + src/PolyType/Abstractions/IUnionTypeShape.cs | 5 + src/PolyType/Debugging/DebuggerProxies.cs | 2 + src/PolyType/GenerateShapeAttribute.cs | 3 + src/PolyType/GenerateShapeForAttribute.cs | 6 + src/PolyType/PolyType.csproj | 2 +- .../CSharpUnionCaseShape.cs | 71 ++++++ .../ReflectionProvider/CSharpUnionHelpers.cs | 76 ++++++ .../CSharpUnionTypeShape.cs | 108 +++++++++ .../CSharpUnionValueAccessorHelper.cs | 9 + .../ClosedHierarchyHelpers.cs | 125 ++++++++++ .../FSharpUnionTypeShape.cs | 1 + .../ReflectionEnumTypeShape.cs | 4 + .../ReflectionTypeShapeOptions.cs | 5 + .../ReflectionTypeShapeProvider.cs | 75 ++++++ .../ReflectionUnionTypeShape.cs | 1 + .../SourceGenModel/DelegateMarshaler.cs | 29 +++ .../SourceGenModel/SourceGenEnumTypeShape.cs | 3 + .../SourceGenModel/SourceGenUnionTypeShape.cs | 5 + src/PolyType/TypeShapeAttribute.cs | 22 ++ src/PolyType/UnionKind.cs | 34 +++ tests/PolyType.Tests/CSharpUnionTests.cs | 220 ++++++++++++++++++ tests/PolyType.Tests/ProviderUnderTest.cs | 33 ++- .../SourceGenModel/ObsoletePropertyTests.cs | 1 + 41 files changed, 1216 insertions(+), 8 deletions(-) create mode 100644 src/PolyType.Examples/JsonSerializer/Converters/JsonCSharpUnionConverter.cs create mode 100644 src/PolyType.TestCases/MockRuntimeAttributes.cs create mode 100644 src/PolyType/ReflectionProvider/CSharpUnionCaseShape.cs create mode 100644 src/PolyType/ReflectionProvider/CSharpUnionHelpers.cs create mode 100644 src/PolyType/ReflectionProvider/CSharpUnionTypeShape.cs create mode 100644 src/PolyType/ReflectionProvider/CSharpUnionValueAccessorHelper.cs create mode 100644 src/PolyType/ReflectionProvider/ClosedHierarchyHelpers.cs create mode 100644 src/PolyType/SourceGenModel/DelegateMarshaler.cs create mode 100644 src/PolyType/UnionKind.cs create mode 100644 tests/PolyType.Tests/CSharpUnionTests.cs diff --git a/src/PolyType.Examples/JsonSerializer/Converters/JsonCSharpUnionConverter.cs b/src/PolyType.Examples/JsonSerializer/Converters/JsonCSharpUnionConverter.cs new file mode 100644 index 00000000..e542f4d4 --- /dev/null +++ b/src/PolyType.Examples/JsonSerializer/Converters/JsonCSharpUnionConverter.cs @@ -0,0 +1,67 @@ +using PolyType.Abstractions; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PolyType.Examples.JsonSerializer.Converters; + +internal sealed class JsonCSharpUnionConverter( + Getter getUnionCaseIndex, + JsonUnionCaseConverter[] unionCaseConverters) : JsonConverter +{ + public override TUnion? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (default(TUnion) is null && reader.TokenType is JsonTokenType.Null) + { + return default; + } + + int bestIndex = FindBestMatchingCase(in reader); + if (bestIndex < 0) + { + throw new JsonException($"Unable to match JSON token to any case type of union '{typeof(TUnion)}'."); + } + + return unionCaseConverters[bestIndex].Read(ref reader, typeToConvert, options); + } + + public override void Write(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + int index = getUnionCaseIndex(ref value); + if (index < 0) + { + throw new JsonException($"Unable to determine union case for value of type '{value?.GetType()}'."); + } + + unionCaseConverters[index].WriteDirect(writer, value, options); + } + + private int FindBestMatchingCase(ref readonly Utf8JsonReader reader) + { + // Simple structural matching: score each case type against the JSON token. + // For objects, count how many JSON property names match known properties on each candidate. + // For primitives, check binary token type compatibility. + + Utf8JsonReader snapshot = reader; + JsonTokenType token = snapshot.TokenType; + int bestIndex = -1; + int bestScore = -1; + + for (int i = 0; i < unionCaseConverters.Length; i++) + { + int score = unionCaseConverters[i].ScoreAgainstToken(token, ref snapshot); + if (score > bestScore) + { + bestScore = score; + bestIndex = i; + } + } + + return bestIndex; + } +} diff --git a/src/PolyType.Examples/JsonSerializer/Converters/JsonObjectConverter.cs b/src/PolyType.Examples/JsonSerializer/Converters/JsonObjectConverter.cs index 94a21f27..94a5460b 100644 --- a/src/PolyType.Examples/JsonSerializer/Converters/JsonObjectConverter.cs +++ b/src/PolyType.Examples/JsonSerializer/Converters/JsonObjectConverter.cs @@ -10,6 +10,11 @@ namespace PolyType.Examples.JsonSerializer.Converters; internal class JsonObjectConverter(JsonPropertyConverter[] properties) : JsonConverter, IJsonObjectConverter { private readonly JsonPropertyConverter[] _propertiesToWrite = properties.Where(prop => prop.HasGetter).ToArray(); + private readonly JsonPropertyDictionary>? _propertyLookup = properties.Length > 0 + ? properties.ToJsonPropertyDictionary(p => p.Name) + : null; + + public bool HasProperty(ref Utf8JsonReader reader) => _propertyLookup?.LookupProperty(ref reader) is not null; public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { diff --git a/src/PolyType.Examples/JsonSerializer/Converters/JsonUnionCaseConverter.cs b/src/PolyType.Examples/JsonSerializer/Converters/JsonUnionCaseConverter.cs index 775ff168..ff8fed00 100644 --- a/src/PolyType.Examples/JsonSerializer/Converters/JsonUnionCaseConverter.cs +++ b/src/PolyType.Examples/JsonSerializer/Converters/JsonUnionCaseConverter.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Collections; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; @@ -7,6 +8,18 @@ namespace PolyType.Examples.JsonSerializer.Converters; internal abstract class JsonUnionCaseConverter : JsonConverter { public abstract string Name { get; } + + /// + /// Scores this case type against a JSON token for structural matching. + /// Higher scores indicate better matches. Returns -1 for incompatible tokens. + /// + public virtual int ScoreAgainstToken(JsonTokenType token, ref readonly Utf8JsonReader reader) => 0; + + /// + /// Writes the union value directly (without discriminator wrapping) for C# union serialization. + /// + public virtual void WriteDirect(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) => + Write(writer, value, options); } internal sealed class JsonUnionCaseConverter(string name, IMarshaler marshaler, JsonConverter underlying) : JsonUnionCaseConverter @@ -54,4 +67,74 @@ public override void Write(Utf8JsonWriter writer, TUnion value, JsonSerializerOp underlying.Write(writer, marshaler.Unmarshal(value)!, options); } } + + public override void WriteDirect(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) + { + TUnionCase? caseValue = marshaler.Unmarshal(value); + underlying.Write(writer, caseValue!, options); + } + + public override int ScoreAgainstToken(JsonTokenType token, ref readonly Utf8JsonReader reader) + { + // Binary token type compatibility check + Type caseType = typeof(TUnionCase); + + return token switch + { + JsonTokenType.Number => IsNumericType(caseType) ? 1 : -1, + JsonTokenType.String => IsStringType(caseType) ? 1 : -1, + JsonTokenType.True or JsonTokenType.False => caseType == typeof(bool) ? 1 : -1, + JsonTokenType.StartArray => IsArrayType(caseType) ? 1 : -1, + JsonTokenType.StartObject when _objectConverter is not null => ScoreObjectMatch(in reader), + JsonTokenType.StartObject => 0, + _ => 0, + }; + } + + private int ScoreObjectMatch(ref readonly Utf8JsonReader reader) + { + // Count how many JSON property names match known properties of this case type + if (underlying is not JsonObjectConverter objectConverter) + { + return 0; + } + + Utf8JsonReader checkpoint = reader; + int matchCount = 0; + + try + { + checkpoint.EnsureRead(); // past StartObject + while (checkpoint.TokenType == JsonTokenType.PropertyName) + { + if (objectConverter.HasProperty(ref checkpoint)) + { + matchCount++; + } + + checkpoint.EnsureRead(); // past property name + checkpoint.Skip(); // skip value + checkpoint.EnsureRead(); // to next property or EndObject + } + } + catch (JsonException) + { + return 0; + } + + return matchCount; + } + + private static bool IsNumericType(Type type) => + type == typeof(int) || type == typeof(long) || type == typeof(double) || + type == typeof(float) || type == typeof(decimal) || type == typeof(short) || + type == typeof(byte) || type == typeof(uint) || type == typeof(ulong); + + private static bool IsStringType(Type type) => + type == typeof(string) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || + type == typeof(Guid) || type == typeof(Uri) || type.IsEnum; + + private static bool IsArrayType(Type type) => + type != typeof(string) && + (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type)); } diff --git a/src/PolyType.Examples/JsonSerializer/JsonSerializer.Builder.cs b/src/PolyType.Examples/JsonSerializer/JsonSerializer.Builder.cs index f41b7d31..aafbe0da 100644 --- a/src/PolyType.Examples/JsonSerializer/JsonSerializer.Builder.cs +++ b/src/PolyType.Examples/JsonSerializer/JsonSerializer.Builder.cs @@ -224,7 +224,11 @@ public JsonConverter GetOrAddConverter(ITypeShape shape) => .Select(unionCase => (JsonUnionCaseConverter)unionCase.Accept(this, null)!) .ToArray(); - return new JsonUnionConverter(getUnionCaseIndex, baseTypeConverter, unionCases); + return unionShape.UnionKind switch + { + UnionKind.CSharpUnion => new JsonCSharpUnionConverter(getUnionCaseIndex, unionCases), + _ => new JsonUnionConverter(getUnionCaseIndex, baseTypeConverter, unionCases), + }; } public override object? VisitUnionCase(IUnionCaseShape unionCaseShape, object? state) diff --git a/src/PolyType.Roslyn/Model/EnumDataModel.cs b/src/PolyType.Roslyn/Model/EnumDataModel.cs index 0c612b2d..c17670fc 100644 --- a/src/PolyType.Roslyn/Model/EnumDataModel.cs +++ b/src/PolyType.Roslyn/Model/EnumDataModel.cs @@ -24,4 +24,9 @@ public sealed class EnumDataModel : TypeDataModel /// Gets a value indicating whether the enum is annotated with the . /// public required bool IsFlags { get; init; } + + /// + /// Gets a value indicating whether the enum is a closed enum. + /// + public required bool IsClosed { get; init; } } diff --git a/src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.Enum.cs b/src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.Enum.cs index 636e6d3f..1e58bf68 100644 --- a/src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.Enum.cs +++ b/src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.Enum.cs @@ -32,6 +32,8 @@ private bool TryMapEnum(ITypeSymbol type, ref TypeDataModelGenerationContext ctx bool isFlags = KnownSymbols.FlagsAttribute is { } flagsAttr && enumType.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, flagsAttr)); + bool isClosed = enumType.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == "System.Runtime.CompilerServices.ClosedAttribute"); + model = new EnumDataModel { Type = type, @@ -39,6 +41,7 @@ private bool TryMapEnum(ITypeSymbol type, ref TypeDataModelGenerationContext ctx UnderlyingType = underlyingType, Members = members, IsFlags = isFlags, + IsClosed = isClosed, }; return true; diff --git a/src/PolyType.SourceGenerator/Model/EnumShapeModel.cs b/src/PolyType.SourceGenerator/Model/EnumShapeModel.cs index de04c51b..df88e6d4 100644 --- a/src/PolyType.SourceGenerator/Model/EnumShapeModel.cs +++ b/src/PolyType.SourceGenerator/Model/EnumShapeModel.cs @@ -9,4 +9,6 @@ public sealed record EnumShapeModel : TypeShapeModel public required ImmutableEquatableDictionary Members { get; init; } public required bool IsFlags { get; init; } + + public required bool IsClosed { get; init; } } diff --git a/src/PolyType.SourceGenerator/Model/UnionShapeModel.cs b/src/PolyType.SourceGenerator/Model/UnionShapeModel.cs index 728d1b30..a27b7222 100644 --- a/src/PolyType.SourceGenerator/Model/UnionShapeModel.cs +++ b/src/PolyType.SourceGenerator/Model/UnionShapeModel.cs @@ -6,6 +6,11 @@ public sealed record UnionShapeModel : TypeShapeModel { public required TypeShapeModel UnderlyingModel { get; init; } + /// + /// Gets the name of the UnionKind enum member for this shape (e.g., "ClassHierarchy", "FSharpUnion", "CSharpUnion"). + /// + public required string UnionKindName { get; init; } + /// /// The list of known derived types for the given type in topological order from most to least derived. /// diff --git a/src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs b/src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs index 8d70e919..5426a200 100644 --- a/src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs +++ b/src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs @@ -36,6 +36,7 @@ private TypeShapeModel MapModelCore(TypeDataModel model, TypeId typeId, string s Members = enumModel.Members.ToImmutableEquatableDictionary(m => m.Key, m => EnumValueToString(m.Value)), Attributes = CollectAttributes(model.Type), IsFlags = enumModel.IsFlags, + IsClosed = enumModel.IsClosed, }, OptionalDataModel optionalModel => new OptionalShapeModel @@ -397,6 +398,7 @@ private UnionShapeModel MapUnionModel(TypeDataModel model, TypeShapeModel underl Attributes = CollectAttributes(model.Type), Methods = MapMethods(model, underlyingIncrementalModel.Type), Events = MapEvents(model, underlyingIncrementalModel.Type), + UnionKindName = nameof(UnionKind.ClassHierarchy), UnionCases = model.DerivedTypes .Select(derived => new UnionCaseModel { @@ -923,12 +925,14 @@ private void ParseTypeShapeAttribute( out TypeShapeKind? kind, out ITypeSymbol? marshaler, out MethodShapeFlags? includeMethodFlags, - out Location? location) + out Location? location, + out bool inferDerivedTypes) { kind = null; marshaler = null; location = null; includeMethodFlags = null; + inferDerivedTypes = false; if (typeSymbol.GetAttribute(_knownSymbols.TypeShapeAttribute) is AttributeData propertyAttr) { @@ -946,6 +950,9 @@ private void ParseTypeShapeAttribute( case "IncludeMethods": includeMethodFlags = (MethodShapeFlags)namedArgument.Value.Value!; break; + case "InferDerivedTypes": + inferDerivedTypes = namedArgument.Value.Value is true; + break; } } } diff --git a/src/PolyType.SourceGenerator/Parser/Parser.cs b/src/PolyType.SourceGenerator/Parser/Parser.cs index d192560e..73326776 100644 --- a/src/PolyType.SourceGenerator/Parser/Parser.cs +++ b/src/PolyType.SourceGenerator/Parser/Parser.cs @@ -613,6 +613,16 @@ protected override IEnumerable ResolveDerivedTypes(ITypeSymbol derivedType = dt; } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _knownSymbols.ClosedSubtypeAttribute)) + { + // [ClosedSubtype(typeof(T))] — emitted by the compiler for closed hierarchies + if (attribute.ConstructorArguments is not [{ Value: ITypeSymbol closedDt }]) + { + continue; + } + + derivedType = closedDt; + } else { continue; @@ -680,7 +690,8 @@ protected override TypeDataModelGenerationStatus MapType(ITypeSymbol type, TypeD out TypeShapeKind? attrDeclaredKind, out ITypeSymbol? marshaler, out MethodShapeFlags? attrMethodBindingFlags, - out Location? typeShapeLocation); + out Location? typeShapeLocation, + out bool inferDerivedTypes); TypeExtensionModel? typeExtensionModel = GetExtensionModel(type); if (typeExtensionModel is not null) diff --git a/src/PolyType.SourceGenerator/PolyType.SourceGenerator.csproj b/src/PolyType.SourceGenerator/PolyType.SourceGenerator.csproj index 5588efbb..786fbbaf 100644 --- a/src/PolyType.SourceGenerator/PolyType.SourceGenerator.csproj +++ b/src/PolyType.SourceGenerator/PolyType.SourceGenerator.csproj @@ -8,6 +8,7 @@ + diff --git a/src/PolyType.SourceGenerator/PolyTypeKnownSymbols.cs b/src/PolyType.SourceGenerator/PolyTypeKnownSymbols.cs index 2f7a45bd..a75fe0e4 100644 --- a/src/PolyType.SourceGenerator/PolyTypeKnownSymbols.cs +++ b/src/PolyType.SourceGenerator/PolyTypeKnownSymbols.cs @@ -111,4 +111,16 @@ public static class EnumMemberShapeAttributePropertyNames public INamedTypeSymbol? AttributeUsageAttribute => GetOrResolveType("System.AttributeUsageAttribute", ref _AttributeUsageAttribute); private Option _AttributeUsageAttribute; + + public INamedTypeSymbol? ClosedAttribute => GetOrResolveType("System.Runtime.CompilerServices.ClosedAttribute", ref _ClosedAttribute); + private Option _ClosedAttribute; + + public INamedTypeSymbol? ClosedSubtypeAttribute => GetOrResolveType("System.Runtime.CompilerServices.ClosedSubtypeAttribute", ref _ClosedSubtypeAttribute); + private Option _ClosedSubtypeAttribute; + + public INamedTypeSymbol? UnionAttribute => GetOrResolveType("System.Runtime.CompilerServices.UnionAttribute", ref _UnionAttribute); + private Option _UnionAttribute; + + public INamedTypeSymbol? IUnionInterface => GetOrResolveType("System.IUnion", ref _IUnionInterface); + private Option _IUnionInterface; } \ No newline at end of file diff --git a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Enum.cs b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Enum.cs index 2f021a8c..99089871 100644 --- a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Enum.cs +++ b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Enum.cs @@ -21,6 +21,7 @@ private void FormatEnumTypeShapeFactory(SourceWriter writer, string methodName, Members = {{memberDictionaryFactoryName}}(), AttributeFactory = {{FormatNull(attributeFactoryName)}}, IsFlags = {{FormatBool(enumTypeShape.IsFlags)}}, + IsClosed = {{FormatBool(enumTypeShape.IsClosed)}}, Provider = this, }; } diff --git a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.FSharpUnion.cs b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.FSharpUnion.cs index 43d53f56..34179dcf 100644 --- a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.FSharpUnion.cs +++ b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.FSharpUnion.cs @@ -18,6 +18,7 @@ private void FormatFSharpUnionTypeShapeFactory(SourceWriter writer, string metho { return new global::PolyType.SourceGenModel.SourceGenUnionTypeShape<{{unionShapeModel.Type.FullyQualifiedName}}> { + UnionKind = global::PolyType.UnionKind.FSharpUnion, BaseTypeFactory = () => {{unionShapeModel.UnderlyingModel.SourceIdentifier}}, UnionCasesFactory = {{createUnionCasesMethodName}}, GetUnionCaseIndex = {{FormatFSharpUnionTagReader(unionShapeModel)}}, diff --git a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Union.cs b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Union.cs index f641ed64..9217ae63 100644 --- a/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Union.cs +++ b/src/PolyType.SourceGenerator/SourceFormatter/SourceFormatter.Union.cs @@ -20,6 +20,7 @@ private void FormatUnionTypeShapeFactory(SourceWriter writer, string methodName, { return new global::PolyType.SourceGenModel.SourceGenUnionTypeShape<{{unionShapeModel.Type.FullyQualifiedName}}> { + UnionKind = global::PolyType.UnionKind.{{unionShapeModel.UnionKindName}}, BaseTypeFactory = () => {{unionShapeModel.UnderlyingModel.SourceIdentifier}}, UnionCasesFactory = {{createUnionCasesMethodName}}, GetUnionCaseIndex = {{getUnionCaseIndexMethod}}, diff --git a/src/PolyType.TestCases/MockRuntimeAttributes.cs b/src/PolyType.TestCases/MockRuntimeAttributes.cs new file mode 100644 index 00000000..da6b5205 --- /dev/null +++ b/src/PolyType.TestCases/MockRuntimeAttributes.cs @@ -0,0 +1,45 @@ +using System; + +// Mock attributes matching the metadata shape the C# compiler will produce. +// These live in System.Runtime.CompilerServices to match the expected full names. + +namespace System.Runtime.CompilerServices +{ + /// + /// Mock attribute for closed enums and closed class hierarchies. + /// The C# compiler will emit this on types declared with the closed modifier. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Enum | AttributeTargets.Struct, Inherited = false)] + public sealed class ClosedAttribute : Attribute; + + /// + /// Mock attribute for declaring the permitted direct subtypes of a closed hierarchy. + /// The C# compiler will emit one of these for each direct subtype. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true, Inherited = false)] + public sealed class ClosedSubtypeAttribute : Attribute + { + public ClosedSubtypeAttribute(Type subtypeType) => SubtypeType = subtypeType; + public Type SubtypeType { get; } + } + + /// + /// Mock attribute marking a type as a C# union type. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] + public sealed class UnionAttribute : Attribute; +} + +namespace System +{ + /// + /// Mock interface implemented by C# union types to provide access to the wrapped value. + /// + public interface IUnion + { + /// + /// Gets the value held by the union. + /// + object? Value { get; } + } +} diff --git a/src/PolyType.TestCases/TestTypes.cs b/src/PolyType.TestCases/TestTypes.cs index 6049413c..f85bbcd1 100644 --- a/src/PolyType.TestCases/TestTypes.cs +++ b/src/PolyType.TestCases/TestTypes.cs @@ -1,4 +1,4 @@ -using Microsoft.FSharp.Collections; +using Microsoft.FSharp.Collections; using Microsoft.FSharp.Core; using PolyType.Abstractions; using PolyType.Tests.FSharp; @@ -715,6 +715,21 @@ public static IEnumerable GetTestCasesCore() yield return TestCase.Create(new ClassWithPrivateStaticEvent()); yield return TestCase.Create(new DerivedInterfaceWithEvent.Impl()); yield return TestCase.Create(new DerivedInterfaceWithEvent.Impl()); + + // C# union types (mock) — Dog and Cat are regular objects + yield return TestCase.Create(new Dog { Name = "Rex", Age = 5 }, p); + yield return TestCase.Create(new Cat { Name = "Whiskers", IsIndoor = true }, p); + + // Closed enums (mock) + yield return TestCase.Create(ClosedColor.Red, additionalValues: [ClosedColor.Green, ClosedColor.Blue], provider: p); + + // Closed hierarchies (mock) + yield return TestCase.Create(new Circle { Color = "Red", Radius = 3.14 }, + additionalValues: [new Rectangle { Color = "Blue", Width = 2.0, Height = 4.0 }], + isUnion: true); + yield return TestCase.Create(new Car { Make = "Toyota", Doors = 4 }, + additionalValues: [new Truck { Make = "Ford", PayloadTons = 5.0 }], + isUnion: true); } private static ExpandoObject CreateExpandoObject(IEnumerable> values) @@ -3314,6 +3329,92 @@ public delegate Task LargeAsyncDelegate( int p51, int p52, int p53, int p54, int p55, int p56, int p57, int p58, int p59, int p60, int p61, int p62, int p63, int p64, int p65, int p66, int p67, int p68, int p69, int p70); +// --- C# Union Types (mock) --- + +[GenerateShape] +public partial class Dog +{ + public string Name { get; set; } = ""; + public int Age { get; set; } +} + +[GenerateShape] +public partial class Cat +{ + public string Name { get; set; } = ""; + public bool IsIndoor { get; set; } +} + +[System.Runtime.CompilerServices.Union] +[GenerateShape] +public partial class Pet : IUnion +{ + private readonly object? _value; + + public Pet(Dog value) => _value = value; + public Pet(Cat value) => _value = value; + public Pet(int value) => _value = value; + + public object? Value => _value; +} + +// --- Closed Enum Types (mock) --- + +[System.Runtime.CompilerServices.Closed] +public enum ClosedColor +{ + Red, + Green, + Blue, +} + +// --- Closed Class Hierarchy (mock) --- + +[System.Runtime.CompilerServices.Closed] +[System.Runtime.CompilerServices.ClosedSubtype(typeof(Circle))] +[System.Runtime.CompilerServices.ClosedSubtype(typeof(Rectangle))] +[GenerateShape] +public abstract partial class Shape +{ + public string Color { get; set; } = "Black"; +} + +[GenerateShape] +public partial class Circle : Shape +{ + public double Radius { get; set; } +} + +[GenerateShape] +public partial class Rectangle : Shape +{ + public double Width { get; set; } + public double Height { get; set; } +} + +// Closed hierarchy with [ClosedSubtype] attributes and InferDerivedTypes fallback +[System.Runtime.CompilerServices.Closed] +[System.Runtime.CompilerServices.ClosedSubtype(typeof(Car))] +[System.Runtime.CompilerServices.ClosedSubtype(typeof(Truck))] +[TypeShape(InferDerivedTypes = true)] +[GenerateShape] +public abstract partial class Vehicle +{ + public string Make { get; set; } = "Unknown"; +} + +[GenerateShape] +public partial class Car : Vehicle +{ + public int Doors { get; set; } +} + +[GenerateShape] +public partial class Truck : Vehicle +{ + public double PayloadTons { get; set; } +} + [GenerateShapeFor] [GenerateShapeFor] [GenerateShapeFor] @@ -3578,4 +3679,14 @@ public delegate Task LargeAsyncDelegate( [GenerateShapeFor, int>>] [GenerateShapeFor>>] [GenerateShapeFor>>>>] -public partial class Witness; \ No newline at end of file +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +[GenerateShapeFor] +public partial class Witness; diff --git a/src/PolyType/Abstractions/IEnumTypeShape.cs b/src/PolyType/Abstractions/IEnumTypeShape.cs index e6a0aa7a..e48367bf 100644 --- a/src/PolyType/Abstractions/IEnumTypeShape.cs +++ b/src/PolyType/Abstractions/IEnumTypeShape.cs @@ -15,6 +15,14 @@ public interface IEnumTypeShape : ITypeShape /// Gets a value indicating whether the enum is annotated with the . /// bool IsFlags { get; } + + /// + /// Gets a value indicating whether the enum is a closed enum. + /// + /// + /// A closed enum restricts its values to the declared members only. + /// + bool IsClosed { get; } } /// diff --git a/src/PolyType/Abstractions/IUnionTypeShape.cs b/src/PolyType/Abstractions/IUnionTypeShape.cs index dd862b97..eb8a35ed 100644 --- a/src/PolyType/Abstractions/IUnionTypeShape.cs +++ b/src/PolyType/Abstractions/IUnionTypeShape.cs @@ -10,6 +10,11 @@ [InternalImplementationsOnly] public interface IUnionTypeShape : ITypeShape { + /// + /// Gets the kind of union represented by this shape. + /// + UnionKind UnionKind { get; } + /// /// Gets the underlying type shape of the union base type. /// diff --git a/src/PolyType/Debugging/DebuggerProxies.cs b/src/PolyType/Debugging/DebuggerProxies.cs index dbef5fa7..c3b8e56c 100644 --- a/src/PolyType/Debugging/DebuggerProxies.cs +++ b/src/PolyType/Debugging/DebuggerProxies.cs @@ -69,6 +69,7 @@ internal sealed class EnumTypeShapeDebugView(IEnumTypeShape typeShape) : TypeSha { public ITypeShape UnderlyingType => typeShape.UnderlyingType; public bool IsFlags => typeShape.IsFlags; + public bool IsClosed => typeShape.IsClosed; } /// @@ -95,6 +96,7 @@ internal sealed class SurrogateTypeShapeDebugView(ISurrogateTypeShape typeShape) [ExcludeFromCodeCoverage] internal sealed class UnionTypeShapeDebugView(IUnionTypeShape typeShape) : TypeShapeDebugView(typeShape), IUnionTypeShape { + public UnionKind UnionKind => typeShape.UnionKind; public ITypeShape BaseType => typeShape.BaseType; public IReadOnlyList UnionCases => typeShape.UnionCases; } diff --git a/src/PolyType/GenerateShapeAttribute.cs b/src/PolyType/GenerateShapeAttribute.cs index e59393a2..c72fc01f 100644 --- a/src/PolyType/GenerateShapeAttribute.cs +++ b/src/PolyType/GenerateShapeAttribute.cs @@ -27,4 +27,7 @@ public sealed class GenerateShapeAttribute : Attribute /// public MethodShapeFlags IncludeMethods { get; init; } + + /// + public bool InferDerivedTypes { get; init; } } \ No newline at end of file diff --git a/src/PolyType/GenerateShapeForAttribute.cs b/src/PolyType/GenerateShapeForAttribute.cs index b4ea030c..ca420454 100644 --- a/src/PolyType/GenerateShapeForAttribute.cs +++ b/src/PolyType/GenerateShapeForAttribute.cs @@ -31,6 +31,9 @@ public sealed class GenerateShapeForAttribute : Attribute /// public MethodShapeFlags IncludeMethods { get; init; } + + /// + public bool InferDerivedTypes { get; init; } } /// @@ -95,4 +98,7 @@ public GenerateShapeForAttribute(string typeNamePattern) /// public MethodShapeFlags IncludeMethods { get; init; } + + /// + public bool InferDerivedTypes { get; init; } } diff --git a/src/PolyType/PolyType.csproj b/src/PolyType/PolyType.csproj index e9dde5fa..b1e82df0 100644 --- a/src/PolyType/PolyType.csproj +++ b/src/PolyType/PolyType.csproj @@ -6,7 +6,7 @@ README.md true $(DefineConstants);IS_MAIN_POLYTYPE_PROJECT - true + false 1.0.0 diff --git a/src/PolyType/ReflectionProvider/CSharpUnionCaseShape.cs b/src/PolyType/ReflectionProvider/CSharpUnionCaseShape.cs new file mode 100644 index 00000000..1917e716 --- /dev/null +++ b/src/PolyType/ReflectionProvider/CSharpUnionCaseShape.cs @@ -0,0 +1,71 @@ +using PolyType.Abstractions; +using PolyType.SourceGenModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace PolyType.ReflectionProvider; + +[DebuggerTypeProxy(typeof(PolyType.Debugging.UnionCaseShapeDebugView))] +[DebuggerDisplay("{DebuggerDisplay,nq}")] +[RequiresDynamicCode(ReflectionTypeShapeProvider.RequiresDynamicCodeMessage)] +[RequiresUnreferencedCode(ReflectionTypeShapeProvider.RequiresUnreferencedCodeMessage)] +internal sealed class CSharpUnionCaseShape( + CSharpUnionCaseInfo caseInfo, + ReflectionTypeShapeProvider provider) : IUnionCaseShape +{ + public ITypeShape UnionCaseType => _type ?? CommonHelpers.ExchangeIfNull(ref _type, provider.GetTypeShape()); + private ITypeShape? _type; + + public IMarshaler Marshaler => _marshaler ?? CommonHelpers.ExchangeIfNull(ref _marshaler, CreateMarshaler()); + private IMarshaler? _marshaler; + + public string Name => caseInfo.Name; + public int Tag => caseInfo.Tag; + public bool IsTagSpecified => caseInfo.IsTagSpecified; + public int Index => caseInfo.Index; + + ITypeShape IUnionCaseShape.UnionCaseType => UnionCaseType; + + public object? Accept(TypeShapeVisitor visitor, object? state = null) => visitor.VisitUnionCase(this, state); + + private string DebuggerDisplay => $"{{Name = \"{Name}\", CaseType = \"{typeof(TUnionCase)}\"}}"; + + private DelegateMarshaler CreateMarshaler() + { + // Marshal: create union value from case value via constructor + Func marshal; + if (caseInfo.MarshalConstructor is { } ctor) + { + marshal = caseValue => + { + object? result = ctor.Invoke([caseValue]); + + return (TUnion?)result; + }; + } + else + { + marshal = _ => default; + } + + // Unmarshal: extract case value from union via reflection-based IUnion.Value accessor + Func valueAccessor = caseInfo.ValueAccessor; + Func unmarshal = unionValue => + { + if (unionValue is null) + { + return default; + } + + object? val = valueAccessor(unionValue); + if (val is TUnionCase caseVal) + { + return caseVal; + } + + return default; + }; + + return new DelegateMarshaler(marshal, unmarshal); + } +} diff --git a/src/PolyType/ReflectionProvider/CSharpUnionHelpers.cs b/src/PolyType/ReflectionProvider/CSharpUnionHelpers.cs new file mode 100644 index 00000000..89188294 --- /dev/null +++ b/src/PolyType/ReflectionProvider/CSharpUnionHelpers.cs @@ -0,0 +1,76 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace PolyType.ReflectionProvider; + +[RequiresUnreferencedCode(ReflectionTypeShapeProvider.RequiresUnreferencedCodeMessage)] +internal static class CSharpUnionHelpers +{ + private const string UnionAttributeFullName = "System.Runtime.CompilerServices.UnionAttribute"; + private const string IUnionFullName = "System.IUnion"; + + public static bool IsCSharpUnion(Type type) + { + bool hasUnionAttribute = false; + foreach (CustomAttributeData attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.FullName == UnionAttributeFullName) + { + hasUnionAttribute = true; + break; + } + } + + if (!hasUnionAttribute) + { + return false; + } + + return FindIUnionInterface(type) is not null; + } + + public static CSharpUnionCaseInfo[] GetCaseInfos(Type unionType, out Func valueAccessor) + { + // Resolve IUnion.Value property via reflection + Type? iUnionInterface = FindIUnionInterface(unionType) + ?? throw new InvalidOperationException($"Type '{unionType}' does not implement IUnion."); + + PropertyInfo valueProperty = iUnionInterface.GetProperty("Value") + ?? throw new InvalidOperationException($"IUnion interface on '{unionType}' does not have a Value property."); + + // Build a fast accessor delegate for IUnion.Value + MethodInfo getter = valueProperty.GetGetMethod()!; + valueAccessor = obj => getter.Invoke(obj, null); + + // Extract case types from single-parameter public constructors + var caseInfos = new List(); + int index = 0; + + foreach (ConstructorInfo ctor in unionType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)) + { + ParameterInfo[] parameters = ctor.GetParameters(); + if (parameters.Length == 1) + { + Type caseType = parameters[0].ParameterType; + string name = caseType.Name; + caseInfos.Add(new CSharpUnionCaseInfo(caseType, name, index, index, IsTagSpecified: false, MarshalConstructor: ctor, ValueAccessor: valueAccessor)); + index++; + } + } + + return caseInfos.ToArray(); + } + + private static Type? FindIUnionInterface(Type type) + { + foreach (Type iface in type.GetInterfaces()) + { + if (iface.FullName == IUnionFullName) + { + return iface; + } + } + + return null; + } +} diff --git a/src/PolyType/ReflectionProvider/CSharpUnionTypeShape.cs b/src/PolyType/ReflectionProvider/CSharpUnionTypeShape.cs new file mode 100644 index 00000000..50f029ef --- /dev/null +++ b/src/PolyType/ReflectionProvider/CSharpUnionTypeShape.cs @@ -0,0 +1,108 @@ +using PolyType.Abstractions; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace PolyType.ReflectionProvider; + +[DebuggerTypeProxy(typeof(PolyType.Debugging.UnionTypeShapeDebugView))] +[RequiresDynamicCode(ReflectionTypeShapeProvider.RequiresDynamicCodeMessage)] +[RequiresUnreferencedCode(ReflectionTypeShapeProvider.RequiresUnreferencedCodeMessage)] +internal sealed class CSharpUnionTypeShape(CSharpUnionCaseInfo[] caseInfos, Func valueAccessor, ReflectionTypeShapeProvider provider, ReflectionTypeShapeOptions options) + : ReflectionTypeShape(provider, options), IUnionTypeShape +{ + public override TypeShapeKind Kind => TypeShapeKind.Union; + public override object? Accept(TypeShapeVisitor visitor, object? state = null) => visitor.VisitUnion(this, state); + public UnionKind UnionKind => UnionKind.CSharpUnion; + + public ITypeShape BaseType => _baseType ?? CommonHelpers.ExchangeIfNull(ref _baseType, (ITypeShape)Provider.CreateTypeShapeCore(typeof(TUnion), allowUnionShapes: false)); + private ITypeShape? _baseType; + + public IReadOnlyList UnionCases => _unionCases ?? CommonHelpers.ExchangeIfNull(ref _unionCases, CreateUnionCaseShapes().AsReadOnlyList()); + private IReadOnlyList? _unionCases; + + public Getter GetGetUnionCaseIndex() + { + return _unionCaseIndexReader ??= CommonHelpers.ExchangeIfNull(ref _unionCaseIndexReader, CreateUnionCaseIndexGetter()); + } + + private Getter? _unionCaseIndexReader; + + ITypeShape IUnionTypeShape.BaseType => BaseType; + + private IEnumerable CreateUnionCaseShapes() + { + foreach (CSharpUnionCaseInfo caseInfo in caseInfos) + { + yield return Provider.CreateCSharpUnionCaseShape(this, caseInfo); + } + } + + private Getter CreateUnionCaseIndexGetter() + { + var cache = new ConcurrentDictionary(); + foreach (CSharpUnionCaseInfo caseInfo in caseInfos) + { + cache.TryAdd(caseInfo.CaseType, caseInfo.Index); + } + + // Use the IUnion.Value property accessor to determine the active case + return (ref TUnion union) => + { + if (union is null) + { + return -1; + } + + object? val = valueAccessor(union); + if (val is null) + { + return -1; + } + + Type valType = val.GetType(); + if (cache.TryGetValue(valType, out int index)) + { + return index; + } + + return ComputeIndexForType(valType); + }; + + int ComputeIndexForType(Type type) + { + int foundIndex = -1; + foreach (Type parentType in CommonHelpers.TraverseGraphWithTopologicalSort(type, GetParentTypes)) + { + if (cache.TryGetValue(parentType, out int i)) + { + foundIndex = i; + break; + } + } + + cache[type] = foundIndex; + + return foundIndex; + } + + static Type[] GetParentTypes(Type type) + { + var parents = new List(); + if (type.BaseType is { } baseType) + { + parents.Add(baseType); + } + + foreach (Type iface in type.GetInterfaces()) + { + parents.Add(iface); + } + + return parents.ToArray(); + } + } +} + +internal sealed record CSharpUnionCaseInfo(Type CaseType, string Name, int Tag, int Index, bool IsTagSpecified, ConstructorInfo? MarshalConstructor, Func ValueAccessor); diff --git a/src/PolyType/ReflectionProvider/CSharpUnionValueAccessorHelper.cs b/src/PolyType/ReflectionProvider/CSharpUnionValueAccessorHelper.cs new file mode 100644 index 00000000..03b7d8ac --- /dev/null +++ b/src/PolyType/ReflectionProvider/CSharpUnionValueAccessorHelper.cs @@ -0,0 +1,9 @@ +namespace PolyType.ReflectionProvider; + +internal static class CSharpUnionValueAccessorHelper +{ + public static Func Create(Func untypedAccessor) + { + return union => union is not null ? untypedAccessor(union) : null; + } +} diff --git a/src/PolyType/ReflectionProvider/ClosedHierarchyHelpers.cs b/src/PolyType/ReflectionProvider/ClosedHierarchyHelpers.cs new file mode 100644 index 00000000..7d39edb1 --- /dev/null +++ b/src/PolyType/ReflectionProvider/ClosedHierarchyHelpers.cs @@ -0,0 +1,125 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace PolyType.ReflectionProvider; + +[RequiresUnreferencedCode(ReflectionTypeShapeProvider.RequiresUnreferencedCodeMessage)] +internal static class ClosedHierarchyHelpers +{ + private const string ClosedSubtypeAttributeFullName = "System.Runtime.CompilerServices.ClosedSubtypeAttribute"; + private const string ClosedAttributeFullName = "System.Runtime.CompilerServices.ClosedAttribute"; + + public static bool HasClosedSubtypeAttributes(Type type) + { + foreach (CustomAttributeData attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.FullName == ClosedSubtypeAttributeFullName) + { + return true; + } + } + + return false; + } + + public static bool IsClosed(Type type) + { + foreach (CustomAttributeData attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.FullName == ClosedAttributeFullName) + { + return true; + } + } + + return false; + } + + public static Type[] GetClosedSubtypes(Type type) + { + var subtypes = new List(); + foreach (CustomAttributeData attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.FullName == ClosedSubtypeAttributeFullName && + attr.ConstructorArguments is [{ Value: Type subtypeType }]) + { + subtypes.Add(subtypeType); + } + } + + return subtypes.ToArray(); + } + + [RequiresUnreferencedCode("Assembly.GetTypes() may not return all types in trimmed applications.")] + public static Type[] FindDirectSubtypesInAssembly(Type baseType) + { + var subtypes = new List(); + try + { + foreach (Type candidate in baseType.Assembly.GetTypes()) + { + if (candidate != baseType && !candidate.IsAbstract && baseType.IsAssignableFrom(candidate)) + { + if (candidate.BaseType == baseType || + (baseType.IsInterface && IsDirectInterfaceImplementation(candidate, baseType))) + { + subtypes.Add(candidate); + } + } + } + } + catch (ReflectionTypeLoadException) + { + // Assembly scanning may fail for various reasons; return what we have + } + + return subtypes.ToArray(); + } + + public static DerivedTypeInfo[] GetClosedSubtypeDerivedInfos(Type baseType) + { + Type[] subtypes = GetClosedSubtypes(baseType); + return BuildDerivedInfos(subtypes); + } + + [RequiresUnreferencedCode("Assembly.GetTypes() may not return all types in trimmed applications.")] + public static DerivedTypeInfo[] GetAssemblyScanDerivedInfos(Type baseType) + { + Type[] subtypes = FindDirectSubtypesInAssembly(baseType); + return BuildDerivedInfos(subtypes); + } + + public static DerivedTypeInfo[] GetInferredDerivedTypeInfos(Type baseType) + { + Type[] subtypes = GetClosedSubtypes(baseType); + + if (subtypes.Length == 0) + { + subtypes = FindDirectSubtypesInAssembly(baseType); + } + + return BuildDerivedInfos(subtypes); + } + + private static DerivedTypeInfo[] BuildDerivedInfos(Type[] subtypes) + { + var infos = new DerivedTypeInfo[subtypes.Length]; + for (int i = 0; i < subtypes.Length; i++) + { + Type subtype = subtypes[i]; + infos[i] = new DerivedTypeInfo(subtype, subtype.Name, Tag: i, Index: i, IsTagSpecified: false); + } + + return infos; + } + + private static bool IsDirectInterfaceImplementation(Type candidate, Type interfaceType) + { + if (candidate.BaseType is { } baseType && interfaceType.IsAssignableFrom(baseType)) + { + return false; + } + + return true; + } +} diff --git a/src/PolyType/ReflectionProvider/FSharpUnionTypeShape.cs b/src/PolyType/ReflectionProvider/FSharpUnionTypeShape.cs index 3ebb05aa..fcfbb24a 100644 --- a/src/PolyType/ReflectionProvider/FSharpUnionTypeShape.cs +++ b/src/PolyType/ReflectionProvider/FSharpUnionTypeShape.cs @@ -14,6 +14,7 @@ internal sealed class FSharpUnionTypeShape(FSharpUnionInfo unionInfo, Re { public override TypeShapeKind Kind => TypeShapeKind.Union; public override object? Accept(TypeShapeVisitor visitor, object? state = null) => visitor.VisitUnion(this, state); + public UnionKind UnionKind => UnionKind.FSharpUnion; public ITypeShape BaseType { get; } = new FSharpUnionCaseTypeShape(null, provider, options); public IReadOnlyList UnionCases => _unionCases ?? CommonHelpers.ExchangeIfNull(ref _unionCases, CreateUnionCaseShapes().AsReadOnlyList()); diff --git a/src/PolyType/ReflectionProvider/ReflectionEnumTypeShape.cs b/src/PolyType/ReflectionProvider/ReflectionEnumTypeShape.cs index 84c21856..dce4f07f 100644 --- a/src/PolyType/ReflectionProvider/ReflectionEnumTypeShape.cs +++ b/src/PolyType/ReflectionProvider/ReflectionEnumTypeShape.cs @@ -26,6 +26,10 @@ internal sealed class ReflectionEnumTypeShape public IReadOnlyDictionary Members => _members ?? InitializeMembers(); public bool IsFlags => _isFlags ??= typeof(TEnum).IsDefined(typeof(FlagsAttribute), inherit: false); + public bool IsClosed => _isClosed ??= typeof(TEnum).GetCustomAttributesData() + .Any(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.ClosedAttribute"); + private bool? _isClosed; + private Dictionary InitializeMembers() { FieldInfo[] fields = typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static); diff --git a/src/PolyType/ReflectionProvider/ReflectionTypeShapeOptions.cs b/src/PolyType/ReflectionProvider/ReflectionTypeShapeOptions.cs index 257fe9e9..c38ee4e6 100644 --- a/src/PolyType/ReflectionProvider/ReflectionTypeShapeOptions.cs +++ b/src/PolyType/ReflectionProvider/ReflectionTypeShapeOptions.cs @@ -14,4 +14,9 @@ internal sealed class ReflectionTypeShapeOptions /// public required MethodShapeFlags IncludeMethods { get; init; } + + /// + /// Indicates whether derived types should be inferred from the type hierarchy. + /// + public bool InferDerivedTypes { get; init; } } diff --git a/src/PolyType/ReflectionProvider/ReflectionTypeShapeProvider.cs b/src/PolyType/ReflectionProvider/ReflectionTypeShapeProvider.cs index be66ec22..b6cde1fc 100644 --- a/src/PolyType/ReflectionProvider/ReflectionTypeShapeProvider.cs +++ b/src/PolyType/ReflectionProvider/ReflectionTypeShapeProvider.cs @@ -294,6 +294,12 @@ private IUnionTypeShape CreateUnionTypeShape(Type unionType, FSharpUnionInfo? fS return (IUnionTypeShape)Activator.CreateInstance(fsharpUnionTypeTy, fSharpUnionInfo, this, options)!; } + // Check for C# union types (has [Union] attribute and implements IUnion) + if (CSharpUnionHelpers.IsCSharpUnion(unionType)) + { + return CreateCSharpUnionTypeShape(unionType, options); + } + List derivedTypeAttributes = unionType.GetCustomAttributes().ToList(); if (unionType.GetCustomAttribute() is { }) { @@ -370,10 +376,49 @@ private IUnionTypeShape CreateUnionTypeShape(Type unionType, FSharpUnionInfo? fS derivedTypeInfos.Add(new(derivedType, name, tag, index, isTagSpecified)); } + // If no explicit derived types were declared, try [ClosedSubtype] attributes + // (always honored) or assembly scanning (requires InferDerivedTypes opt-in) + if (derivedTypeInfos.Count == 0) + { + DerivedTypeInfo[] closedSubtypeInfos = ClosedHierarchyHelpers.GetClosedSubtypeDerivedInfos(unionType); + if (closedSubtypeInfos.Length > 0) + { + derivedTypeInfos.AddRange(closedSubtypeInfos); + } + else if (options.InferDerivedTypes) + { + DerivedTypeInfo[] inferredInfos = ClosedHierarchyHelpers.GetAssemblyScanDerivedInfos(unionType); + derivedTypeInfos.AddRange(inferredInfos); + } + } + Type unionTypeTy = typeof(ReflectionUnionTypeShape<>).MakeGenericType(unionType); return (IUnionTypeShape)Activator.CreateInstance(unionTypeTy, derivedTypeInfos.ToArray(), this, options)!; } + private IUnionTypeShape CreateCSharpUnionTypeShape(Type unionType, ReflectionTypeShapeOptions options) + { + CSharpUnionCaseInfo[] caseInfos = CSharpUnionHelpers.GetCaseInfos(unionType, out Func valueAccessor); + // Create a typed accessor delegate: Func + // We wrap the untyped accessor to work with the generic shape + Type csharpUnionTypeTy = typeof(CSharpUnionTypeShape<>).MakeGenericType(unionType); + // Build Func from Func + Type funcType = typeof(Func<,>).MakeGenericType(unionType, typeof(object)); + Delegate typedAccessor = CreateTypedValueAccessor(unionType, valueAccessor); + + return (IUnionTypeShape)Activator.CreateInstance(csharpUnionTypeTy, caseInfos, typedAccessor, this, options)!; + } + + private static Delegate CreateTypedValueAccessor(Type unionType, Func valueAccessor) + { + // Creates a Func from a Func + // using a helper method to avoid reflection emit + Type helperType = typeof(CSharpUnionValueAccessorHelper<>).MakeGenericType(unionType); + var createMethod = helperType.GetMethod("Create", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)!; + + return (Delegate)createMethod.Invoke(null, [valueAccessor])!; + } + private IFunctionTypeShape CreateFunctionTypeShape(Type functionType, FSharpFuncInfo? fSharpFuncInfo, ReflectionTypeShapeOptions options) { if (fSharpFuncInfo is not null) @@ -440,6 +485,7 @@ private ReflectionTypeShapeOptions ResolveTypeShapeOptions(Type type) RequestedKind = requestedKind, Marshaler = marshaler, IncludeMethods = methodFlags ?? MethodShapeFlags.None, + InferDerivedTypes = typeShapeAttr?.GetRequestedInferDerivedTypes() ?? false, }; } @@ -509,6 +555,11 @@ private static TypeShapeKind DetermineBuiltInTypeKind( if (allowUnionShapes) { + if (CSharpUnionHelpers.IsCSharpUnion(type)) + { + return TypeShapeKind.Union; + } + var customAttributeData = type.GetCustomAttributesData(); if (customAttributeData.Any(attrData => attrData.AttributeType == typeof(DerivedTypeShapeAttribute))) { @@ -520,6 +571,23 @@ private static TypeShapeKind DetermineBuiltInTypeKind( { return TypeShapeKind.Union; } + + // InferDerivedTypes: discover subtypes from [ClosedSubtype] or assembly scanning + if ((type.IsClass || type.IsInterface) && !type.IsSealed) + { + // [ClosedSubtype] attributes are always honored (similar to [DerivedTypeShapeAttribute]) + if (ClosedHierarchyHelpers.HasClosedSubtypeAttributes(type)) + { + return TypeShapeKind.Union; + } + + // Assembly scanning requires explicit opt-in via InferDerivedTypes + if (options?.InferDerivedTypes == true && + ClosedHierarchyHelpers.FindDirectSubtypesInAssembly(type).Length > 0) + { + return TypeShapeKind.Union; + } + } } if (typeof(IDictionary).IsAssignableFrom(type)) @@ -605,6 +673,13 @@ internal IUnionCaseShape CreateUnionCaseShape(IUnionTypeShape unionTypeShape, De return (IUnionCaseShape)Activator.CreateInstance(unionCaseType, unionTypeShape, derivedTypeInfo, this)!; } + internal IUnionCaseShape CreateCSharpUnionCaseShape(IUnionTypeShape unionTypeShape, CSharpUnionCaseInfo caseInfo) + { + Type unionCaseType = typeof(CSharpUnionCaseShape<,>).MakeGenericType(caseInfo.CaseType, unionTypeShape.Type); + + return (IUnionCaseShape)Activator.CreateInstance(unionCaseType, caseInfo, this)!; + } + internal static IMethodShapeInfo CreateTupleConstructorShapeInfo(Type tupleType) { Debug.Assert(tupleType.IsTupleType()); diff --git a/src/PolyType/ReflectionProvider/ReflectionUnionTypeShape.cs b/src/PolyType/ReflectionProvider/ReflectionUnionTypeShape.cs index 6c5ad47c..deae7f6c 100644 --- a/src/PolyType/ReflectionProvider/ReflectionUnionTypeShape.cs +++ b/src/PolyType/ReflectionProvider/ReflectionUnionTypeShape.cs @@ -13,6 +13,7 @@ internal sealed class ReflectionUnionTypeShape(DerivedTypeInfo[] derived { public override TypeShapeKind Kind => TypeShapeKind.Union; public override object? Accept(TypeShapeVisitor visitor, object? state = null) => visitor.VisitUnion(this, state); + public UnionKind UnionKind => UnionKind.ClassHierarchy; public ITypeShape BaseType => _baseType ?? CommonHelpers.ExchangeIfNull(ref _baseType, (ITypeShape)Provider.CreateTypeShapeCore(typeof(TUnion), allowUnionShapes: false)); private ITypeShape? _baseType; diff --git a/src/PolyType/SourceGenModel/DelegateMarshaler.cs b/src/PolyType/SourceGenModel/DelegateMarshaler.cs new file mode 100644 index 00000000..3559df65 --- /dev/null +++ b/src/PolyType/SourceGenModel/DelegateMarshaler.cs @@ -0,0 +1,29 @@ +namespace PolyType.SourceGenModel; + +/// +/// Defines a delegate-based marshaler between two types without any type constraints. +/// +/// The source type. +/// The target type. +public sealed class DelegateMarshaler : IMarshaler +{ + private readonly Func _marshal; + private readonly Func _unmarshal; + + /// + /// Initializes a new instance of the class. + /// + /// A delegate that converts from to . + /// A delegate that converts from to . + public DelegateMarshaler(Func marshal, Func unmarshal) + { + _marshal = marshal; + _unmarshal = unmarshal; + } + + /// + public TTarget? Marshal(TSource? value) => _marshal(value); + + /// + public TSource? Unmarshal(TTarget? value) => _unmarshal(value); +} diff --git a/src/PolyType/SourceGenModel/SourceGenEnumTypeShape.cs b/src/PolyType/SourceGenModel/SourceGenEnumTypeShape.cs index fc8a9dc4..580c0a5e 100644 --- a/src/PolyType/SourceGenModel/SourceGenEnumTypeShape.cs +++ b/src/PolyType/SourceGenModel/SourceGenEnumTypeShape.cs @@ -35,6 +35,9 @@ public ITypeShape UnderlyingType /// public bool IsFlags { get; init; } + /// + public bool IsClosed { get; init; } + /// public override object? Accept(TypeShapeVisitor visitor, object? state = null) => visitor.VisitEnum(this, state); diff --git a/src/PolyType/SourceGenModel/SourceGenUnionTypeShape.cs b/src/PolyType/SourceGenModel/SourceGenUnionTypeShape.cs index f85ecaa1..a079b386 100644 --- a/src/PolyType/SourceGenModel/SourceGenUnionTypeShape.cs +++ b/src/PolyType/SourceGenModel/SourceGenUnionTypeShape.cs @@ -10,6 +10,11 @@ namespace PolyType.SourceGenModel; [DebuggerTypeProxy(typeof(PolyType.Debugging.UnionTypeShapeDebugView))] public sealed class SourceGenUnionTypeShape : SourceGenTypeShape, IUnionTypeShape { + /// + /// Gets the kind of union represented by this shape. + /// + public required UnionKind UnionKind { get; init; } + /// /// Gets a delayed base type shape factory for use with potentially recursive type graphs. /// diff --git a/src/PolyType/TypeShapeAttribute.cs b/src/PolyType/TypeShapeAttribute.cs index e6a8e9ee..6686652f 100644 --- a/src/PolyType/TypeShapeAttribute.cs +++ b/src/PolyType/TypeShapeAttribute.cs @@ -60,6 +60,28 @@ public TypeShapeKind Kind /// public MethodShapeFlags IncludeMethods { get => _includeMethods ?? MethodShapeFlags.None; init => _includeMethods = value; } + /// + /// Gets a value indicating whether derived types should be automatically inferred for this type. + /// + /// + /// + /// When set to , the type shape provider will attempt to auto-discover derived types: + /// + /// + /// + /// Fast path: Read [ClosedSubtype] attributes emitted by the compiler for closed class hierarchies. + /// + /// + /// Slow path (reflection only): Scan the type's assembly for direct subtypes. + /// + /// + /// + /// Explicit annotations always take precedence over auto-inferred types. + /// + /// + public bool InferDerivedTypes { get; init; } + internal TypeShapeKind? GetRequestedKind() => _kind; internal MethodShapeFlags? GetRequestedIncludeMethods() => _includeMethods; + internal bool? GetRequestedInferDerivedTypes() => InferDerivedTypes ? true : null; } diff --git a/src/PolyType/UnionKind.cs b/src/PolyType/UnionKind.cs new file mode 100644 index 00000000..54f76d36 --- /dev/null +++ b/src/PolyType/UnionKind.cs @@ -0,0 +1,34 @@ +#if IS_MAIN_POLYTYPE_PROJECT +using PolyType.Abstractions; +#endif + +namespace PolyType; + +/// +/// Defines the kind of union represented by an . +/// +#if IS_MAIN_POLYTYPE_PROJECT +public +#else +internal +#endif +enum UnionKind +{ + /// + /// A class or interface hierarchy with derived types declared + /// via or equivalent annotations. + /// + ClassHierarchy = 0, + + /// + /// An F# discriminated union. + /// + FSharpUnion = 1, + + /// + /// A C# union type declared with [Union] and implementing IUnion. + /// Case types are extracted from single-parameter constructors or + /// IUnionMembers factory methods. + /// + CSharpUnion = 2, +} diff --git a/tests/PolyType.Tests/CSharpUnionTests.cs b/tests/PolyType.Tests/CSharpUnionTests.cs new file mode 100644 index 00000000..37bc712f --- /dev/null +++ b/tests/PolyType.Tests/CSharpUnionTests.cs @@ -0,0 +1,220 @@ +using PolyType.Abstractions; +using PolyType.ReflectionProvider; +using PolyType.Tests; + +namespace PolyType.Tests; + +/// +/// Tests for C# union types, closed enums, and closed class hierarchies. +/// These features are prototypes based on upcoming C# language proposals. +/// +public class CSharpUnionTests +{ + private static readonly ReflectionTypeShapeProvider s_reflectionProvider = + ReflectionTypeShapeProvider.Create(new ReflectionTypeShapeProviderOptions + { + UseReflectionEmit = true, + }); + + [Fact] + public void Pet_IsDetectedAsUnion() + { + ITypeShape shape = s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + + Assert.Equal(TypeShapeKind.Union, shape.Kind); + } + + [Fact] + public void Pet_UnionKind_IsCSharpUnion() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + + Assert.Equal(UnionKind.CSharpUnion, unionShape.UnionKind); + } + + [Fact] + public void Pet_UnionCases_MatchConstructorParameterTypes() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + + Assert.Equal(3, unionShape.UnionCases.Count); + Assert.Equal(typeof(Dog), unionShape.UnionCases[0].UnionCaseType.Type); + Assert.Equal(typeof(Cat), unionShape.UnionCases[1].UnionCaseType.Type); + Assert.Equal(typeof(int), unionShape.UnionCases[2].UnionCaseType.Type); + } + + [Fact] + public void Pet_UnionCaseNames_AreTypeNames() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + + Assert.Equal("Dog", unionShape.UnionCases[0].Name); + Assert.Equal("Cat", unionShape.UnionCases[1].Name); + Assert.Equal("Int32", unionShape.UnionCases[2].Name); + } + + [Fact] + public void Pet_GetGetUnionCaseIndex_IdentifiesDogCase() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + Getter getCaseIndex = unionShape.GetGetUnionCaseIndex(); + Pet pet = new Pet(new Dog { Name = "Rex", Age = 3 }); + + int index = getCaseIndex(ref pet); + + Assert.Equal(0, index); + } + + [Fact] + public void Pet_GetGetUnionCaseIndex_IdentifiesCatCase() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + Getter getCaseIndex = unionShape.GetGetUnionCaseIndex(); + Pet pet = new Pet(new Cat { Name = "Whiskers", IsIndoor = true }); + + int index = getCaseIndex(ref pet); + + Assert.Equal(1, index); + } + + [Fact] + public void Pet_GetGetUnionCaseIndex_IdentifiesIntCase() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + Getter getCaseIndex = unionShape.GetGetUnionCaseIndex(); + Pet pet = new Pet(42); + + int index = getCaseIndex(ref pet); + + Assert.Equal(2, index); + } + + [Fact] + public void Pet_BaseType_IsObjectShapeOfPet() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Pet)); + + Assert.Equal(typeof(Pet), unionShape.BaseType.Type); + Assert.Equal(TypeShapeKind.Object, unionShape.BaseType.Kind); + } + + [Fact] + public void ExistingSubtypeUnion_UnionKind_IsClassHierarchy() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(PolymorphicClass)); + + Assert.Equal(UnionKind.ClassHierarchy, unionShape.UnionKind); + } + + [Fact] + public void ClosedColor_IsClosed_ReturnsTrue() + { + var enumShape = (IEnumTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(ClosedColor)); + + Assert.True(enumShape.IsClosed); + } + + [Fact] + public void RegularEnum_IsClosed_ReturnsFalse() + { + var enumShape = (IEnumTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(StringComparison)); + + Assert.False(enumShape.IsClosed); + } + + [Fact] + public void Shape_WithClosedSubtype_IsDetectedAsUnion() + { + ITypeShape shape = s_reflectionProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Assert.Equal(TypeShapeKind.Union, shape.Kind); + } + + [Fact] + public void Shape_UnionKind_IsClassHierarchy() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Assert.Equal(UnionKind.ClassHierarchy, unionShape.UnionKind); + } + + [Fact] + public void Shape_InferredDerivedTypes_IncludeCircleAndRectangle() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Type[] caseTypes = unionShape.UnionCases.Select(c => c.UnionCaseType.Type).ToArray(); + Assert.Contains(typeof(Circle), caseTypes); + Assert.Contains(typeof(Rectangle), caseTypes); + } + + [Fact] + public void Vehicle_WithInferDerivedTypes_DiscoversDerivedTypesFromAssemblyScan() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Vehicle)); + + Type[] caseTypes = unionShape.UnionCases.Select(c => c.UnionCaseType.Type).ToArray(); + Assert.Contains(typeof(Car), caseTypes); + Assert.Contains(typeof(Truck), caseTypes); + } + + [Fact] + public void Shape_GetGetUnionCaseIndex_IdentifiesCircle() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Shape)); + Getter getCaseIndex = unionShape.GetGetUnionCaseIndex(); + Shape circle = new Circle { Color = "Red", Radius = 5.0 }; + + int index = getCaseIndex(ref circle); + + Assert.True(index >= 0, "Circle should match a union case"); + Assert.Equal(typeof(Circle), unionShape.UnionCases[index].UnionCaseType.Type); + } + + [Fact] + public void Vehicle_GetGetUnionCaseIndex_IdentifiesCar() + { + var unionShape = (IUnionTypeShape)s_reflectionProvider.GetTypeShapeOrThrow(typeof(Vehicle)); + Getter getCaseIndex = unionShape.GetGetUnionCaseIndex(); + Vehicle car = new Car { Make = "Toyota", Doors = 4 }; + + int index = getCaseIndex(ref car); + + Assert.True(index >= 0, "Car should match a union case"); + Assert.Equal(typeof(Car), unionShape.UnionCases[index].UnionCaseType.Type); + } + + [Fact] + public void SourceGen_ClosedColor_IsClosed_ReturnsTrue() + { + var enumShape = (IEnumTypeShape)Witness.GeneratedTypeShapeProvider.GetTypeShapeOrThrow(typeof(ClosedColor)); + + Assert.True(enumShape.IsClosed); + } + + [Fact] + public void SourceGen_Shape_WithClosedSubtype_IsDetectedAsUnion() + { + ITypeShape shape = Witness.GeneratedTypeShapeProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Assert.Equal(TypeShapeKind.Union, shape.Kind); + } + + [Fact] + public void SourceGen_Shape_UnionCases_IncludeCircleAndRectangle() + { + var unionShape = (IUnionTypeShape)Witness.GeneratedTypeShapeProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Type[] caseTypes = unionShape.UnionCases.Select(c => c.UnionCaseType.Type).ToArray(); + Assert.Contains(typeof(Circle), caseTypes); + Assert.Contains(typeof(Rectangle), caseTypes); + } + + [Fact] + public void SourceGen_Shape_UnionKind_IsClassHierarchy() + { + var unionShape = (IUnionTypeShape)Witness.GeneratedTypeShapeProvider.GetTypeShapeOrThrow(typeof(Shape)); + + Assert.Equal(UnionKind.ClassHierarchy, unionShape.UnionKind); + } +} diff --git a/tests/PolyType.Tests/ProviderUnderTest.cs b/tests/PolyType.Tests/ProviderUnderTest.cs index be4ec5c2..1a03c9cc 100644 --- a/tests/PolyType.Tests/ProviderUnderTest.cs +++ b/tests/PolyType.Tests/ProviderUnderTest.cs @@ -1,4 +1,5 @@ using Microsoft.FSharp.Reflection; +using PolyType.Abstractions; using PolyType.ReflectionProvider; using PolyType.SourceGenModel; using System.Collections; @@ -21,7 +22,12 @@ public bool HasConstructor(ITestCase testCase) { if (testCase.IsUnion) { - return !testCase.IsAbstract || FSharpType.IsUnion(testCase.Type, null); + if (!testCase.IsAbstract) + { + return true; + } + + return FSharpType.IsUnion(testCase.Type, null) || HasConstructibleSubtype(testCase.Type); } return !(testCase.IsAbstract && !typeof(IEnumerable).IsAssignableFrom(testCase.Type)) && @@ -31,6 +37,31 @@ public bool HasConstructor(ITestCase testCase) testCase.CustomKind is not TypeShapeKind.None && (!testCase.UsesSpanConstructor || Kind is not ProviderKind.ReflectionNoEmit); } + + private static bool HasConstructibleSubtype(Type type) + { + foreach (DerivedTypeShapeAttribute attr in type.GetCustomAttributes(false)) + { + if (!attr.Type.IsAbstract && !attr.Type.IsInterface) + { + return true; + } + } + + foreach (Attribute attr in type.GetCustomAttributes(false).OfType()) + { + if (attr.GetType().FullName is "System.Runtime.CompilerServices.ClosedSubtypeAttribute") + { + Type? subtypeType = (Type?)attr.GetType().GetProperty("SubtypeType")?.GetValue(attr); + if (subtypeType is { IsAbstract: false, IsInterface: false }) + { + return true; + } + } + } + + return false; + } } public enum ProviderKind diff --git a/tests/PolyType.Tests/SourceGenModel/ObsoletePropertyTests.cs b/tests/PolyType.Tests/SourceGenModel/ObsoletePropertyTests.cs index 9d6f1791..042659ab 100644 --- a/tests/PolyType.Tests/SourceGenModel/ObsoletePropertyTests.cs +++ b/tests/PolyType.Tests/SourceGenModel/ObsoletePropertyTests.cs @@ -184,6 +184,7 @@ public static void SourceGenUnionTypeShape_BaseType_ObsoleteProperty_HasDesiredE var unionShape = new SourceGenUnionTypeShape { Provider = mockProvider, + UnionKind = UnionKind.ClassHierarchy, BaseTypeFactory = () => throw new InvalidOperationException("BaseTypeFactory should not be called"), BaseType = expectedBaseType, UnionCasesFactory = () => [],