diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c2e253a..6e3d434 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -26,10 +26,16 @@ jobs: dotnet-version: ${{ matrix.dotnet }} - name: Restore dependencies - run: dotnet restore +# run: dotnet restore + run: | + dotnet restore ./Tests/Tests.csproj + dotnet restore ./Tests.AOT/Tests.AOT.csproj - name: Build solution - run: dotnet build --configuration Release --no-restore +# run: dotnet build --configuration Release --no-restore + run: | + dotnet build ./Tests/Tests.csproj --configuration Release --no-restore + dotnet build ./Tests.AOT/Tests.AOT.csproj --configuration Release --no-restore - name: Run unit tests run: dotnet test ./Tests/Tests.csproj --configuration Release --no-build --verbosity normal diff --git a/README.md b/README.md index 963deb9..5dd59df 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,16 @@ A highly configurable C\# discriminated union library --- -1. [Why use `SumSharp`?](#why-use-sumsharp) +1. [Why use SumSharp?](#why-use-sumsharp) 2. [Installation](#installation) 3. [Quick start](#quick-start) - [Creating a DU type](#creating-a-du-type) - [Empty cases](#empty-cases) - [Generic cases](#generic-cases) - [The `Match` function](#the-match-function) + - [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching) 4. [Motivation](#motivation) + - [SumSharp vs .NET 11 union types](#sumsharp-vs-net-11-union-types) - [What about `OneOf`?](#what-about-oneof) - [Typical DU implementation approaches](#typical-du-implementation-approaches) - [SumSharp's approach](#sumsharps-approach) @@ -39,12 +41,13 @@ A highly configurable C\# discriminated union library Discriminated unions, also known as sum types, are an invaluable tool for working with heterogenous data types in code. They help ensure safe data access patterns and can [make illegal states unrepresentable.](https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/) -There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages. +There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages. Union types are being added to C# with the [.NET 11 release](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union), but these are not true DUs because they lack case names and thus cannot support multiple cases of the same type. `SumSharp` aims to be **the most powerful, expressive, and configurable C\# discriminated union library available**. Its goal is to provide features and syntax comparable to the discriminated union types natively offered by languages such as F\#, Rust, and Haskell. Although it's impossible to exactly replicate the functionality these other languages offer, `SumSharp` strives to get as close as possible. ### Features +- **Integration with .NET 11 union types, allowing use of C#'s built-in pattern matching syntax** - Unlimited number of cases - Support for class, struct, record, and record struct unions - Support for generic unions @@ -88,6 +91,7 @@ partial class StringOrDouble That's it! `SumSharp` will generate members for the `StringOrDouble` class that allow it to be used as a discriminated union type. These members include: +- `Value` and `HasValue` properties, and `TryGetValue` methods to satisfy requirements for a non-boxing .NET 11 union type - `String` and `Double` static functions that construct instances of `StringOrDouble` - `AsString` and `AsDouble` properties that return either the underlying string/double value or throw an `InvalidOperationException` - `IsString` and `IsDouble` boolean properties @@ -141,7 +145,7 @@ Case types can be generic. To define a generic case you must supply the **name** ```csharp [UnionCase("Some", "T")] [UnionCase("None")] -partial class Optional +partial class Option { } @@ -151,12 +155,14 @@ Note that generic types in general *must be fully qualified names unless you hav ### The `Match` function -`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Optional` type defined in the last section to equivalent F\# code. +**If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. In most cases using built-in C# pattern matching will be easier than using the `Match` function. See [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching)** + +`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Option` type defined in the last section to equivalent F\# code. ```csharp -// Here myOptionalValue is an Optional +// Here myOptionValue is an Option // The "None" handler can come before the "Some" handler as long as they're both named -var result = myOptionalValue.Match( +var result = myOptionValue.Match( None: () => "", Some: x => x); ``` @@ -164,7 +170,7 @@ var result = myOptionalValue.Match( Corresponding F\# code would look like: ```fsharp -let result = match myOptionalValue with +let result = match myOptionValue with | None -> "" | Some x -> x ``` @@ -174,7 +180,7 @@ Handling each case is not required, but a warning will be emitted by the `SumSha If you only want to handle some subset of cases, you can provide a default handler to prevent a warning from being emitted. ```csharp -var result = myOptionalValue.Match( +var result = myOptionValue.Match( Some: x => x, _: () => ""); ``` @@ -182,18 +188,60 @@ var result = myOptionalValue.Match( Again, the corresponding F\# code would look like: ```fsharp -let result = match myOptionalValue with +let result = match myOptionValue with | Some x -> x | _ -> "" ``` The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match` that is already exhaustive. It will also emit a warning if any case handlers are specified by position rather than name. Specifying by name is preferred because it makes the code clearer and prevents bugs/compilation errors if the case ordering changes. +### .NET 11 union types and pattern matching + +If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. All `SumSharp` unions implement [the non-boxing access pattern](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#non-boxing-access-pattern) and [union member providers](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#union-member-providers). + +Because C# union types do not support case names, `SumSharp` generates wrapper structs for each case in the union and places them as the same namespace/nested type level as the union itself. Empty cases get empty `partial` structs. This means that two `SumSharp` unions that share the same namespace/type heirarchy **cannot share identical non-empty case names**. These structs are used when pattern matching using built-in C# syntax such as `switch` or `is`. An example using the `Option` type that was defined above: + +```csharp +var x = Option.Some(4); + +var value = x switch +{ + Some(var i) => i, + None => 0, +}; + +// prints "value is 4" +Console.WriteLine($"value is {value}"); + +// prints "x is 4" +if (x is Some(4)) +{ + Console.WriteLine("x is 4"); +} +else if (x is None) +{ + Console.WriteLine("x is none"); +} + +``` + +#### Type union implementation details + +* `SumSharp` unions are *never null*. A non-null `SumSharp` union will never match with the `null` pattern, even if the underlying data it stores is null. +* The `IUnionMembers.Value` property is never null and will always return a boxed instance of one of the case structs. +* The `IUnionMembers.HasValue` property always returns true. +* The various `TryGetValue` overloads will wrap the underlying data in one of the case structs. +* `SumSharp` unions implement their corresponding `IUnionMembers` interface explicitly. This means that the `Value` and `HasValue` properties and the `TryGetValue` methods cannot be used unless you explicitly cast it to an `IUnionMembers`. In general you should not need to use any of these: they exist to satisfy the compiler's requirements for custom union types. + --- ## Motivation -C\# unfortunately does not offer discriminated unions as a language feature. Although [a proposal](https://github.com/dotnet/csharplang/blob/18a527bcc1f0bdaf542d8b9a189c50068615b439/proposals/TypeUnions.md) has existed for a while, this feature doesn't seem to be coming in the near future. +### `SumSharp` vs .NET 11 union types + +The union types introduced by .NET 11 are not true disrciminated unions because they lack the ability to define case names, thus not allowing for multiple cases of the same type. They also always box value types by default, which is unnecessary and often undesireable. They do, however, provide highly convenient pattern matching syntax using C\#'s built-in pattern matching operations such as `switch` and `is`. + +As mentioned in the quick start guide, `SumSharp` unions satisfy the requirements for .NET 11 union types. Wrapper structs are defined for each case, allowing for pattern matching behavior that is similar to languages with first class DUs such as F\#. Thus, `SumSharp` works synergistically with C\#'s unions types. You don't need to choose between the two: using `SumSharp` gives you the best of both. ### What about `OneOf`? diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index fc79579..9efc1ee 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -16,6 +17,8 @@ internal class SymbolHandler private const string IL2026SupressAttribute = "[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"IL2026:RequiresUnreferencedCode\", Justification = \"It is the library consumer's responsibility to ensure the required types are preserved.\")]"; private const string IL3050SupressAttribute = "[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"AOT\", \"IL3050:AotAnalysisWarning\", Justification = \"It is the library consumer's responsibility to ensure the required types are preserved.\")]"; + private static readonly string GeneratedCodeAttribute = $"[System.CodeDom.Compiler.GeneratedCode(\"SumSharp\", \"{Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion}\")]"; + public abstract class TypeInfo { public abstract string Name { get; } @@ -255,7 +258,10 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public CaseData[] UniqueCases { get; } - public TypeInfo[] DistinctTypes { get; } + public Dictionary Net11StructNameMap { get; } + + // Cases grouped by type + public IGrouping[] CaseGroups { get; } public INamedTypeSymbol[] ContainingTypes; @@ -419,36 +425,86 @@ public SymbolHandler( }) .ToArray(); - var typeMap = new Dictionary(); - - foreach (var caseData in Cases) - { - if (caseData.TypeInfo is null) - { - continue; - } - - typeMap[caseData.TypeInfo.Name] = caseData.TypeInfo; - } - - DistinctTypes = [..typeMap.Values]; + CaseGroups = + [..Cases + .Where(caseData => caseData.TypeInfo is not null) + .GroupBy(caseData => + caseData.TypeInfo!.IsTupleType ? + $"({string.Join(", ", caseData.TypeInfo.TupleTypeArgs)})" : // Removes custom field names + caseData.TypeInfo.Name)]; - if (storageStrategy == 0 && DistinctTypes.Length == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) + if (storageStrategy == 0 && CaseGroups.Length == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) { Cases = [.. Cases.Select(caseData => new CaseData(caseData.Index, caseData.Name, caseData.TypeInfo, false, caseData.StorageMode, FullUnmanagedStorageTypeName))]; } UniqueCases = - Cases - .Where(caseData => caseData.TypeInfo is not null) - .GroupBy(caseData => - caseData.TypeInfo!.IsTupleType ? - $"({string.Join(", ", caseData.TypeInfo.TupleTypeArgs)})" : // Removes custom field names - caseData.TypeInfo.Name) + CaseGroups .Where(group => group.Count() == 1) .SelectMany(group => group) .ToArray(); + Net11StructNameMap = Cases.ToDictionary(caseData => caseData, caseData => + { + if (caseData.TypeInfo is null || !caseData.TypeInfo.IsGeneric) + { + return (caseData.Name, ""); + } + else + { + var parsedTypeArguments = TypeNameParser.ExtractLeafTypes(caseData.TypeInfo.Name); + + var caseStructTypeArguments = TypeArguments.Intersect(parsedTypeArguments).ToArray(); + + var caseStructTypeConstraints = + caseStructTypeArguments + .Select(typeArg => + { + var typeSymbol = (ITypeParameterSymbol)allGenericTypeArguments.Single(symbol => symbol.Name == typeArg); + + var constraints = new List(); + + if (typeSymbol.HasNotNullConstraint) + { + constraints.Add("notnull"); + } + else if (typeSymbol.HasReferenceTypeConstraint) + { + constraints.Add("class"); + } + + if (typeSymbol.HasUnmanagedTypeConstraint) + { + constraints.Add("unmanaged"); + } + else if (typeSymbol.HasValueTypeConstraint) + { + constraints.Add("struct"); + } + + if (typeSymbol.HasConstructorConstraint) + { + constraints.Add("new()"); + } + + if (constraints.Count == 0) + { + return ""; + } + + return $"where {typeArg} : {string.Join(", ", constraints)}"; + }) + .Where(contraints => contraints.Length > 0) + .ToArray(); + + var nameWithTypeArgs = $"{caseData.Name}{(caseStructTypeArguments.Length == 0 ? "" : $"<{string.Join(", ", caseStructTypeArguments)}>")}"; + + var constraints = string.Join(" ", caseStructTypeConstraints); + + return (nameWithTypeArgs, constraints); + } + }); + var enableJsonSerializationData = symbol! .GetAttributes() @@ -621,6 +677,8 @@ public string Emit() EmitCaseConstructors(); + EmitNativeUnion(); + EmitAs(); EmitIs(); @@ -737,23 +795,63 @@ private void EmitFieldsAndConstructor() fieldNameTypeMap[caseData.FieldType!] = caseData.FieldName!; } - List interfaces = []; + string interfaces = ": "; if (!DisableValueEquality) { - interfaces.Add($"System.IEquatable<{Name}>"); + interfaces += $@" + System.IEquatable<{Name}>"; } if (IsDisposable) { - interfaces.Add("System.IDisposable"); + interfaces += @", + System.IDisposable"; } if (IsAsyncDisposable) { - interfaces.Add("System.IAsyncDisposable"); + interfaces += @", + System.IAsyncDisposable"; + } + + if (interfaces == ": ") + { + interfaces = $@" +#if NET11_0_OR_GREATER + : {Name}.IUnionMembers +#endif"; + } + else + { + interfaces += $@" +#if NET11_0_OR_GREATER + , {Name}.IUnionMembers +#endif"; + } + + Builder.AppendLine($@" +#if NET11_0_OR_GREATER"); + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.AppendLine($@" + ///Used to implement .NET 11 union requirements. Use this type when pattern matching using C#'s built-in switch statement + public readonly partial record struct {caseData.Name};"); + } + else + { + Builder.AppendLine($@" + ///Used to implement .NET 11 union requirements. Use this type when pattern matching using C#'s built-in switch statement + {GeneratedCodeAttribute} + public readonly record struct {Net11StructNameMap[caseData].NameWithTypeArgs}({caseData.TypeInfo.Name} Value) {Net11StructNameMap[caseData].Constraints};"); + } } Builder.Append($@" -{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(interfaces.Count == 0 ? "" : $" : {string.Join(", ", interfaces)}")} +[System.Runtime.CompilerServices.Union] +#endif +{GeneratedCodeAttribute} +{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name} {interfaces} {{"); foreach (var field in fieldNameTypeMap) @@ -800,8 +898,7 @@ public void EmitStaticConstructor() { var unmanagedTypes = Cases.Where(caseData => caseData.UseUnmanagedStorage) - .Select(caseData => caseData.TypeInfo!.Name) - .ToImmutableHashSet(); + .Select(caseData => caseData.TypeInfo!.Name); foreach (var type in unmanagedTypes) { @@ -827,7 +924,7 @@ static void CheckUnmanagedStorage() where TUnmanaged__ : unmanaged var _ = new StandardJsonConverter();"); } - Builder.AppendLine(@" + Builder.AppendLine(@" }"); } @@ -838,6 +935,7 @@ public void EmitUnmanagedStorageSize() public static int UnmanagedStorageSize => _unmanagedStorageSize;"); } + public void EmitEquals() { Builder.Append($@" @@ -845,6 +943,7 @@ public void EmitEquals() public bool Equals({Name}{NullableIfRef} other) {{ {(IsStruct ? "" : "if (other is null) return false;")} + {(IsStruct ? "" : "if (ReferenceEquals(this, other)) return true;")} if (Index != other.Index) return false; return Index switch @@ -917,89 +1016,93 @@ public override int GetHashCode() #if NET9_0_OR_GREATER"); } - foreach (var type in DistinctTypes) + foreach (var caseGroup in CaseGroups) { + var type = caseGroup.First().TypeInfo!; + Builder.Append($@" ///Compares a {XMLEscapedName} with a for equality using on the underlying value public static bool operator==({Name} left, {type.Name} right) {{ - return left.Index switch + switch (left.Index) {{"); foreach (var caseData in Cases) { if (caseData.TypeInfo is null) { - Builder.Append($@" - {caseData.Index} => false,"); - continue; } - - switch (type.IsGeneric, caseData.TypeInfo.IsGeneric) + + if (caseData.TypeInfo.IsGeneric || type.IsGeneric) { - case (false, false): - - if (caseData.TypeInfo.IsAlwaysValueType) - { - Builder.Append($@" - {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); - } - else - { - Builder.Append($@" - {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe is null ? right is null : left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); - } - - break; - case (false, true): + if (caseData.TypeInfo.Name == type.Name) + { + Builder.Append($@" + case {caseData.Index}: return typeof({caseData.TypeInfo.Name}).IsValueType ? left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right) : (ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right));"); + } + else if (caseData.TypeInfo.IsAlwaysValueType || type.IsAlwaysValueType) + { + Builder.Append($@" + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - if (type.IsAlwaysValueType) - { - Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right),"); - } - else - { - Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && (ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right)),"); - } + return System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue){NullForgiving}.Equals(right); + }} + break;"); + } + else if (caseData.TypeInfo.IsAlwaysRefType || type.IsAlwaysRefType) + { + Builder.Append($@" + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - break; - case (true, false): + var castedLeftValue = System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue); + return ReferenceEquals(null, castedLeftValue) ? ReferenceEquals(null, right) : castedLeftValue.Equals(right); + }} + break;"); + } + else + { Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe.Equals(right),"); - - break; + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - case (true, true): - { - var expression = new List(); - - if (caseData.TypeInfo.Name != type.Name) - { - expression.Add($"typeof({caseData.TypeInfo.Name}) == typeof({type.Name})"); - } - if (caseData.TypeInfo.IsAlwaysValueType || type.IsAlwaysValueType) - { - expression.Add($"left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right)"); - } - else - { - expression.Add($"(ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right))"); - } - - Builder.Append($@" - {caseData.Index} => {string.Join(" && ", expression)},"); + var castedLeftValue = System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue); - } - break; + return typeof({caseData.TypeInfo.Name}).IsValueType ? castedLeftValue{NullForgiving}.Equals(right) : (ReferenceEquals(null, castedLeftValue) ? ReferenceEquals(null, right) : castedLeftValue.Equals(right)); + }} + break;"); + } + } + else if (caseData.TypeInfo.Name == type.Name) + { + if (caseData.TypeInfo.IsAlwaysValueType) + { + Builder.Append($@" + case {caseData.Index}: return left.As{caseData.Name}Unsafe.Equals(right);"); + } + else + { + Builder.Append($@" + case {caseData.Index}: return ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right);"); + } } } Builder.AppendLine($@" - }}; + default: break; + }} + + return false; }} + ///Compares a with a {XMLEscapedName} for equality using on the underlying value public static bool operator==({type.Name} left, {Name} right) => right == left; @@ -1092,6 +1195,97 @@ private void EmitCaseConstructors() } } + public void EmitNativeUnion() + { + Builder.AppendLine($@" +#if NET11_0_OR_GREATER + public interface IUnionMembers + {{ + ///Returns the underlying value of the union as an {Nullable}. Value types will be boxed + public object Value {{ get; }} + + ///Always returns true. SumSharp unions are always considered non-null, even if the active case is empty + public bool HasValue {{ get; }}"); + + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.AppendLine($@" + ///Returns the singleton . The input value is ignored. This function exists to satisfy the compiler's requirements for .NET 11 union types + public static {Name} Create({Net11StructNameMap[caseData].NameWithTypeArgs} _) => {Name}.{caseData.Name};"); + + } + else + { + Builder.AppendLine($@" + ///Creates a that holds a value of type by invoking the case constructor with .Value + ///This function exists to satisfy the compiler's requirements for .NET 11 union types + public static {Name} Create({Net11StructNameMap[caseData].NameWithTypeArgs} value) => {Name}.{caseData.Name}(value.Value);"); + } + + Builder.AppendLine($@" + ///Attempts to get a value of type from the union. Returns true if the union holds a {caseData.Name}. + ///Returns false otherwise. + ///An out parameter that will be set to the underlying value, if present. + public bool TryGetValue(out {Net11StructNameMap[caseData].NameWithTypeArgs} value);"); + } + + Builder.AppendLine($@" + }}"); + + Builder.Append($@" + object IUnionMembers.Value + {{ + get + {{ + return Index switch + {{"); + + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.Append($@" + {caseData.Index} => new {Net11StructNameMap[caseData].NameWithTypeArgs}(),"); + } + else + { + Builder.Append($@" + {caseData.Index} => new {Net11StructNameMap[caseData].NameWithTypeArgs}(As{caseData.Name}Unsafe),"); + } + } + + Builder.AppendLine($@" + }}; + }} + }} + + bool IUnionMembers.HasValue => true;"); + + foreach (var caseData in Cases) + { + Builder.AppendLine($@" + bool IUnionMembers.TryGetValue(out {Net11StructNameMap[caseData].NameWithTypeArgs} value) + {{ + value = default; + + if (Index != {caseData.Index}) + {{ + return false; + }} + + {(caseData.TypeInfo is null ? "" : $"value = new(As{caseData.Name}Unsafe);")} + + return true; + }}"); + + } + + Builder.AppendLine(@" +#endif"); + } + public void EmitAs() { foreach (var caseData in Cases) @@ -1175,6 +1369,7 @@ public void EmitAs() public ValueTask<{caseData.TypeInfo.Name}> As{caseData.Name}Or(System.Func> defaultValueFactory) => Index == {caseData.Index} ? ValueTask.FromResult(As{caseData.Name}Unsafe) : new ValueTask<{caseData.TypeInfo.Name}>(defaultValueFactory());"); } } + public void EmitIs() { foreach (var caseData in Cases) @@ -1615,6 +1810,7 @@ private void EmitStandardJsonConverter() { Builder.Append($@" ///System.Text.Json converter capable of serializing and deserializing a {XMLEscapedName} + {GeneratedCodeAttribute} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverter<{Name}> {{ {(UsingAOTCompilation ? IL2026SupressAttribute : "")} @@ -1729,6 +1925,7 @@ private void EmitNewtonsoftJsonConverter() { Builder.Append($@" ///Newtonsoft converter capable of serializing and deserializing a {XMLEscapedName} + {GeneratedCodeAttribute} public partial class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter<{Name}> {{ public override {Name}{NullableIfRef} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, {Name}{NullableIfRef} existingValue, bool hasExistingValue, Newtonsoft.Json.JsonSerializer serializer) @@ -1844,6 +2041,7 @@ private void EmitEndClassDeclaration() private void EmitStaticClass() { Builder.Append($@" +{GeneratedCodeAttribute} {Accessibility} static partial class {NameWithoutTypeArguments} {{"); } @@ -1855,6 +2053,7 @@ private void EmitStandardJsonConverterFactory() Builder.Append($@" ///System.Text.Json converter capable of serializing and deserializing any {NameWithoutTypeArguments} {(UsingAOTCompilation ? IL3050SupressAttribute : "")} + {GeneratedCodeAttribute} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverterFactory {{ public override bool CanConvert(System.Type typeToConvert) @@ -1877,45 +2076,45 @@ private void EmitGenericNewtonsoftJsonConverter() var genericTypeDefinition = $"{NameWithoutTypeArguments}<{new string(',', TypeArguments.Length - 1)}>"; Builder.AppendLine($@" -///Newtonsoft converter capable of serializing and deserializing any {NameWithoutTypeArguments} -public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter -{{ - static readonly System.Collections.Concurrent.ConcurrentDictionary _converters = new(); - - private static Newtonsoft.Json.JsonConverter GetConverter(System.Type objectType) + ///Newtonsoft converter capable of serializing and deserializing any {NameWithoutTypeArguments} + {GeneratedCodeAttribute} + public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter {{ - return _converters.GetOrAdd(objectType, static objectType => + static readonly System.Collections.Concurrent.ConcurrentDictionary _converters = new(); + + private static Newtonsoft.Json.JsonConverter GetConverter(System.Type objectType) {{ - var converterType = typeof({genericTypeDefinition}.NewtonsoftJsonConverter).MakeGenericType(objectType.GetGenericArguments()); + return _converters.GetOrAdd(objectType, static objectType => + {{ + var converterType = typeof({genericTypeDefinition}.NewtonsoftJsonConverter).MakeGenericType(objectType.GetGenericArguments()); - return (Newtonsoft.Json.JsonConverter)System.Activator.CreateInstance(converterType); - }}); - }} + return (Newtonsoft.Json.JsonConverter)System.Activator.CreateInstance(converterType); + }}); + }} - public override bool CanConvert(System.Type objectType) - {{ - return objectType.IsGenericType && - objectType.GetGenericTypeDefinition() == typeof({genericTypeDefinition}); - }} + public override bool CanConvert(System.Type objectType) + {{ + return objectType.IsGenericType && + objectType.GetGenericTypeDefinition() == typeof({genericTypeDefinition}); + }} - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object{Nullable} value, Newtonsoft.Json.JsonSerializer serializer) - {{ - if (value is null) + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object{Nullable} value, Newtonsoft.Json.JsonSerializer serializer) {{ - writer.WriteNull(); + if (value is null) + {{ + writer.WriteNull(); - return; - }} + return; + }} - GetConverter(value.GetType()).WriteJson(writer, value, serializer); - }} + GetConverter(value.GetType()).WriteJson(writer, value, serializer); + }} - public override object{Nullable} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, object{Nullable} existingValue, Newtonsoft.Json.JsonSerializer serializer) - {{ - return GetConverter(objectType).ReadJson(reader, objectType, existingValue, serializer); - }} -}} -"); + public override object{Nullable} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, object{Nullable} existingValue, Newtonsoft.Json.JsonSerializer serializer) + {{ + return GetConverter(objectType).ReadJson(reader, objectType, existingValue, serializer); + }} + }}"); } private void EmitEndStaticClass() diff --git a/SumSharp.Generator/TypeNameParser.cs b/SumSharp.Generator/TypeNameParser.cs new file mode 100644 index 0000000..9fbc9ba --- /dev/null +++ b/SumSharp.Generator/TypeNameParser.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SumSharp.Generator; +public static class TypeNameParser +{ + public static List ExtractLeafTypes(string typeName) + { + var parser = new Parser(typeName); + var result = new List(); + + parser.ParseType(result); + + return result; + } + + private sealed class Parser(string typeName) + { + private int _pos = 0; + + public void ParseType(List output) + { + SkipWhitespace(); + + if (Peek() == '(') + { + ParseTuple(output); + return; + } + + string identifier = ParseIdentifier(); + + SkipWhitespace(); + + // Generic? + if (Peek() == '<') + { + Consume('<'); + + while (true) + { + ParseType(output); + + SkipWhitespace(); + + if (Peek() == ',') + { + Consume(','); + continue; + } + + Consume('>'); + break; + } + } + else + { + output.Add(identifier); + } + + // Ignore array suffixes + while (true) + { + SkipWhitespace(); + + if (Peek() != '[') + break; + + Consume('['); + + while (Peek() != ']') + _pos++; + + Consume(']'); + } + + // Optional nullable suffix + if (Peek() == '?') + Consume('?'); + } + + private void ParseTuple(List output) + { + Consume('('); + + while (true) + { + ParseType(output); + + SkipWhitespace(); + + // Skip tuple field name if present + if (char.IsLetter(Peek()) || Peek() == '_') + { + ParseIdentifier(); + } + + SkipWhitespace(); + + if (Peek() == ',') + { + Consume(','); + continue; + } + + Consume(')'); + break; + } + } + + private string ParseIdentifier() + { + SkipWhitespace(); + + int start = _pos; + + while (_pos < typeName.Length) + { + char c = typeName[_pos]; + + if (char.IsLetterOrDigit(c) || c == '_' || c == '.') + { + _pos++; + } + else + { + break; + } + } + + return typeName.Substring(start, _pos - start); + } + + private void SkipWhitespace() + { + while (_pos < typeName.Length && char.IsWhiteSpace(typeName[_pos])) + _pos++; + } + + private char Peek() + { + return _pos < typeName.Length ? typeName[_pos] : '\0'; + } + + private void Consume(char c) + { + if (Peek() != c) + throw new FormatException($"Expected '{c}'."); + + _pos++; + } + } +} \ No newline at end of file diff --git a/SumSharp.sln b/SumSharp.sln index aed0033..a592b21 100644 --- a/SumSharp.sln +++ b/SumSharp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35806.99 +# Visual Studio Version 18 +VisualStudioVersion = 18.8.12023.21 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp", "SumSharp\SumSharp.csproj", "{F757C23E-EF40-420D-AE51-9A0491392968}" EndProject @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AOT", "Tests.AOT\Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp.Analyzer", "SumSharp.Analyzer\SumSharp.Analyzer.csproj", "{D50E46B6-6A70-4A3C-A89F-348A1566825F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Net11", "Tests.Net11\Tests.Net11.csproj", "{76BD98D1-AA76-4FED-8802-7EB256A1A187}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Debug|Any CPU.Build.0 = Debug|Any CPU {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Release|Any CPU.Build.0 = Release|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tests.Net11/Tests.Net11.csproj b/Tests.Net11/Tests.Net11.csproj new file mode 100644 index 0000000..d330533 --- /dev/null +++ b/Tests.Net11/Tests.Net11.csproj @@ -0,0 +1,28 @@ + + + + net11.0 + preview + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests.Net11/Union.cs b/Tests.Net11/Union.cs new file mode 100644 index 0000000..6297728 --- /dev/null +++ b/Tests.Net11/Union.cs @@ -0,0 +1,204 @@ +using SumSharp; + +namespace Tests.Net11; + +public partial class Union +{ + [UnionCase("Int", typeof(int))] + [UnionCase("String", typeof(string))] + [UnionCase("Other", "T")] + + partial class IntOrStringOrOther + { + + } + + [UnionCase("Some", "T")] + [UnionCase("None")] + partial class Option + { + + } + + [UnionCase("IntArray", typeof(int[]))] + [UnionCase("EmptyCase1")] + [UnionCase("EmptyCase2")] + partial class EmptyCases1 + { + + } + + [UnionCase("EmptyCase0")] + [UnionCase("EmptyCase1")] + [UnionCase("FloatArray", typeof(float[]))] + partial class EmptyCases2 + { + + } + + public partial class OuterGeneric + { + public partial class InnerGeneric + where U : class + where V : unmanaged + { + [UnionCase("Case0", "T")] + [UnionCase("Case1", "U[]")] + [UnionCase("Case2", "Dictionary")] + [UnionCase("Case3", "(W[] WArray, bool Boolean)")] + [UnionCase("Case4", "X")] + public partial struct ComplexGeneric + where W : class, new() + where X : struct + { + + } + } + } + + [Fact] + public void Value() + { + Assert.Equal(new Int(5), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Int(5)).Value); + Assert.Equal(new String("abc"), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.String("abc")).Value); + Assert.Equal(new Other(true), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(true)).Value); + Assert.Equal(new Other(4), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(4)).Value); + Assert.Equal(new None(), ((Option.IUnionMembers)Option.None).Value); + Assert.Equal(new EmptyCase1(), ((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase1).Value); + Assert.Equal(new EmptyCase2(), ((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase2).Value); + Assert.Equal(new EmptyCase0(), ((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase0).Value); + Assert.Equal(new EmptyCase1(), ((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase1).Value); + } + + [Fact] + public void HasValue() + { + Assert.True(((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Int(5)).HasValue); + Assert.True(((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(null)).HasValue); + + Assert.True(((Option.IUnionMembers)Option.Some(1)).HasValue); + Assert.True(((Option.IUnionMembers)Option.None).HasValue); + + Assert.True(((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase1).HasValue); + Assert.True(((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase2).HasValue); + Assert.True(((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase0).HasValue); + Assert.True(((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase1).HasValue); + } + + + [Fact] + public void Switch() + { + Assert.True(IntOrStringOrOther.Int(5) switch + { + Int(var i) => i == 5, + String(var s) => false, + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.String("abc") switch + { + Int(var i) => false, + String(var s) => s == "abc", + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.String(null!) switch + { + Int(var i) => false, + String(null) => true, + String => false, + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.Other(true) switch + { + Int(var i) => false, + String(var s) => false, + Other(var b) => b, + }); + + Assert.True(IntOrStringOrOther.Other(4) switch + { + Int(var i) => false, + String(var s) => false, + Other(var i) => i == 4, + }); + + Assert.True(IntOrStringOrOther.Other(null) switch + { + Int(var i) => false, + String(var s) => false, + Other(var i) => !i.HasValue, + }); + + Assert.True(IntOrStringOrOther.Other(null!) switch + { + Int(var i) => false, + String(var s) => false, + Other(var f) => f is null, + }); + + Assert.True(Option.Some("abc") switch + { + Some("abc") => true, + Some => false, + None => false, + }); + + Assert.True(Option.None switch + { + Some => false, + None => true, + }); + + Assert.True(EmptyCases1.IntArray([0]) switch + { + IntArray([0]) => true, + IntArray => false, + EmptyCase1 => false, + EmptyCase2 => false, + }); + + Assert.True(EmptyCases2.EmptyCase1 switch + { + EmptyCase0 => false, + EmptyCase1 => true, + FloatArray => false, + }); + } + + [Fact] + public void ComplexGeneric() + { + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case0("abc") switch + { + OuterGeneric.InnerGeneric.Case0("abc") => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case1([[1.0f], [2.0f, 3.0f]]) switch + { + OuterGeneric.InnerGeneric.Case1([[1.0f], [2.0f, 3.0f]]) => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case2(new() { [4.0] = ([1, 2], [3, 4]) }) switch + { + OuterGeneric.InnerGeneric.Case2, double>(var dict) => dict[4.0] is ([1, 2], [3, 4]), + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case3(([[1], [2, 3]], false)) switch + { + OuterGeneric.InnerGeneric.Case3>(([[1], [2, 3]], false)) => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case4(3.0) switch + { + OuterGeneric.InnerGeneric.Case4(3.0) => true, + _ => false + }); + } +}