Skip to content
Draft
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
@@ -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<TUnion>(
Getter<TUnion, int> getUnionCaseIndex,
JsonUnionCaseConverter<TUnion>[] unionCaseConverters) : JsonConverter<TUnion>
{
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ namespace PolyType.Examples.JsonSerializer.Converters;
internal class JsonObjectConverter<T>(JsonPropertyConverter<T>[] properties) : JsonConverter<T>, IJsonObjectConverter<T>
{
private readonly JsonPropertyConverter<T>[] _propertiesToWrite = properties.Where(prop => prop.HasGetter).ToArray();
private readonly JsonPropertyDictionary<JsonPropertyConverter<T>>? _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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Collections;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand All @@ -7,6 +8,18 @@ namespace PolyType.Examples.JsonSerializer.Converters;
internal abstract class JsonUnionCaseConverter<TUnion> : JsonConverter<TUnion>
{
public abstract string Name { get; }

/// <summary>
/// Scores this case type against a JSON token for structural matching.
/// Higher scores indicate better matches. Returns -1 for incompatible tokens.
/// </summary>
public virtual int ScoreAgainstToken(JsonTokenType token, ref readonly Utf8JsonReader reader) => 0;

/// <summary>
/// Writes the union value directly (without discriminator wrapping) for C# union serialization.
/// </summary>
public virtual void WriteDirect(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) =>
Write(writer, value, options);
}

internal sealed class JsonUnionCaseConverter<TUnionCase, TUnion>(string name, IMarshaler<TUnionCase, TUnion> marshaler, JsonConverter<TUnionCase> underlying) : JsonUnionCaseConverter<TUnion>
Expand Down Expand Up @@ -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<TUnionCase> 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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ public JsonConverter<T> GetOrAddConverter<T>(ITypeShape<T> shape) =>
.Select(unionCase => (JsonUnionCaseConverter<TUnion>)unionCase.Accept(this, null)!)
.ToArray();

return new JsonUnionConverter<TUnion>(getUnionCaseIndex, baseTypeConverter, unionCases);
return unionShape.UnionKind switch
{
UnionKind.CSharpUnion => new JsonCSharpUnionConverter<TUnion>(getUnionCaseIndex, unionCases),
_ => new JsonUnionConverter<TUnion>(getUnionCaseIndex, baseTypeConverter, unionCases),
};
}

public override object? VisitUnionCase<TUnionCase, TUnion>(IUnionCaseShape<TUnionCase, TUnion> unionCaseShape, object? state)
Expand Down
5 changes: 5 additions & 0 deletions src/PolyType.Roslyn/Model/EnumDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ public sealed class EnumDataModel : TypeDataModel
/// Gets a value indicating whether the enum is annotated with the <see cref="FlagsAttribute"/>.
/// </summary>
public required bool IsFlags { get; init; }

/// <summary>
/// Gets a value indicating whether the enum is a closed enum.
/// </summary>
public required bool IsClosed { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ 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,
Requirements = TypeShapeRequirements.Full,
UnderlyingType = underlyingType,
Members = members,
IsFlags = isFlags,
IsClosed = isClosed,
};

return true;
Expand Down
2 changes: 2 additions & 0 deletions src/PolyType.SourceGenerator/Model/EnumShapeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public sealed record EnumShapeModel : TypeShapeModel
public required ImmutableEquatableDictionary<string, string> Members { get; init; }

public required bool IsFlags { get; init; }

public required bool IsClosed { get; init; }
}
5 changes: 5 additions & 0 deletions src/PolyType.SourceGenerator/Model/UnionShapeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ public sealed record UnionShapeModel : TypeShapeModel
{
public required TypeShapeModel UnderlyingModel { get; init; }

/// <summary>
/// Gets the name of the <c>UnionKind</c> enum member for this shape (e.g., "ClassHierarchy", "FSharpUnion", "CSharpUnion").
/// </summary>
public required string UnionKindName { get; init; }

/// <summary>
/// The list of known derived types for the given type in topological order from most to least derived.
/// </summary>
Expand Down
9 changes: 8 additions & 1 deletion src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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)
{
Expand All @@ -946,6 +950,9 @@ private void ParseTypeShapeAttribute(
case "IncludeMethods":
includeMethodFlags = (MethodShapeFlags)namedArgument.Value.Value!;
break;
case "InferDerivedTypes":
inferDerivedTypes = namedArgument.Value.Value is true;
break;
}
}
}
Expand Down
13 changes: 12 additions & 1 deletion src/PolyType.SourceGenerator/Parser/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,16 @@ protected override IEnumerable<DerivedTypeModel> 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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<Compile Include="..\PolyType\TypeShapeKind.cs" Link="Shared\PolyType\%(Filename).cs" />
<Compile Include="..\PolyType\UnionKind.cs" Link="Shared\PolyType\%(Filename).cs" />
<Compile Include="..\PolyType\MethodShapeFlags.cs" Link="Shared\PolyType\%(Filename).cs" />
<Compile Include="..\PolyType\SourceGenModel\Helpers\ValueBitArray.cs" Link="Shared\PolyType\SourceGenModel\Helpers\%(Filename).cs" />
<Compile Include="..\Shared\Helpers\CommonHelpers.cs" Link="PolyType.Roslyn\Shared\Helpers\%(Filename).cs" />
Expand Down
12 changes: 12 additions & 0 deletions src/PolyType.SourceGenerator/PolyTypeKnownSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,16 @@ public static class EnumMemberShapeAttributePropertyNames

public INamedTypeSymbol? AttributeUsageAttribute => GetOrResolveType("System.AttributeUsageAttribute", ref _AttributeUsageAttribute);
private Option<INamedTypeSymbol?> _AttributeUsageAttribute;

public INamedTypeSymbol? ClosedAttribute => GetOrResolveType("System.Runtime.CompilerServices.ClosedAttribute", ref _ClosedAttribute);
private Option<INamedTypeSymbol?> _ClosedAttribute;

public INamedTypeSymbol? ClosedSubtypeAttribute => GetOrResolveType("System.Runtime.CompilerServices.ClosedSubtypeAttribute", ref _ClosedSubtypeAttribute);
private Option<INamedTypeSymbol?> _ClosedSubtypeAttribute;

public INamedTypeSymbol? UnionAttribute => GetOrResolveType("System.Runtime.CompilerServices.UnionAttribute", ref _UnionAttribute);
private Option<INamedTypeSymbol?> _UnionAttribute;

public INamedTypeSymbol? IUnionInterface => GetOrResolveType("System.IUnion", ref _IUnionInterface);
private Option<INamedTypeSymbol?> _IUnionInterface;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down
Loading
Loading