diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3777575..6e3d434 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -14,7 +14,7 @@ jobs: matrix: # Build is broken on ubuntu-latest os: [windows-latest, macos-latest] - dotnet: ["8.0.x", "9.0.x"] + dotnet: ["10.0.x"] steps: - name: Checkout @@ -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/.github/workflows/publish.yml b/.github/workflows/publish.yml index cb2bd0e..716ec71 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,7 +31,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Restore dependencies run: dotnet restore SumSharp/SumSharp.csproj diff --git a/README.md b/README.md index bd85f04..5dd59df 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,23 @@ 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) 5. [Usage Guide](#usage-guide) - [Controlling the memory layout](#controlling-the-memory-layout) - [ValueTuple cases](#valuetuple-cases) + - [IDisposable and IAsyncDisposable cases](#idisposable-and-iasyncdisposable-cases) - [Struct union types](#struct-union-types) - [Generic interface types](#generic-interface-types) - [JSON serialization](#json-serialization) @@ -38,18 +41,20 @@ 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 - Expressive match syntax with exhaustiveness checking - Implicit conversions from types (if there's only one case of that type in the union) - Convenient handling of tuple types +- Automatic implementation of `IDisposable` and `IAsyncDisposable` interfaces - **Highly configurable memory layout**, allowing developers to optimize for their app's memory/perfomance requirements - Built in JSON serialization with both `System.Text.Json` and `Newtonsoft.Json`. Compatible with `System.Text.Json` source generation and AOT compilation - Implicit conversions to/from `OneOf` types @@ -86,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 @@ -139,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 { } @@ -149,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); ``` @@ -162,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 ``` @@ -172,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, _: () => ""); ``` @@ -180,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`? @@ -437,6 +487,53 @@ x.IfCase0((i, s) => Custom field names of tuple types will be preserved when accessed via `As[CaseName]`. +### IDisposable and IAsyncDisposable cases + +If any case holds a type that implements `IDisposable` and/or `IAsyncDisposable`, the union itself will also implement the `IDisposable` and/or `IAsyncDisposable` interfaces, respectively. Additionally, if any case holds a generic type the union will always implement both `IDisposable` and `IAsyncDisposable`. + +```csharp + +class Disposable : IDisposable +{ + // ... +} + +class AsyncDisposable : IAsyncDisposable +{ + //.. +} + +[UnionCase("Case0", typeof(Disposable))] +[UnionCase("Case1", typeof(AsyncDisposable))] +partial class DisposableOrAsyncDisposable +{ + // DisposableOrAsyncDisposable implements both IDisposable and IAsyncDisposable +} + +// .. +{ + using DisposableOrAsyncDisposable w = new Disposable(); +} // w.Dispose() will be called, which in turn will call Dispose() on the underlying Disposable + +{ + await using DisposableOrAsyncDisposable x = new AsyncDisposable(); +} // x.DisposeAsync() will be called, which in turn will call DisposeAsync() on the underlying AsyncDisposable + +{ + await using DisposableOrAsyncDisposable y = new Disposable(); +} // y.DisposeAsync() will be called, which in turn will call Dispose() on the underlying Disposable + +{ + using DisposableOrAsyncDisposable z = new AsyncDisposable(); +} // z.Dispose() will be called, which WILL NOT call DisposeAsync() on the underlying AsyncDisposable +``` + +The generated `Dispose()` method will call `Dispose()` on the underlying value iff the value is an instance of a type that implements `IDisposable`. The generated `DisposeAsync()` method will call `DisposeAsync()` OR `Dispose()` on the underlying value iff the value is an instance of a type that implements `IAsyncDisposable` or `IDisposable`, respectively. + +Be aware that `Dispose()` WILL NOT attempt to call `DisposeAsync()` on an underlying value that is an `IAsyncDisposable` but not an `IDisposable`, so if you are using a union that has both `IDisposable` and `IAsyncDisposable` case types you must ensure that you are calling `DisposeAsync()` on the union, or that all case types implement `IDisposable`. Otherwise your `IAsyncDisposable` cases may not be properly disposed. + +The `Dispose()` and `DisposeAsync()` methods on generic unions will use a runtime test to determine if the underlying value implements `IDisposable` or `IAsyncDisposable`. If none of the types implement either of these interfaces, the dispose methods do nothing. + ### Struct union types As mentioned before, `SumSharp` allows for struct and record struct union types. It's important to remember that **any struct union instance that is initialized to `default` is in an invalid state and its behavior is undefined**. The only valid way to create a `SumSharp` union is to use one of its case constructors or conversion operators. C\# allows for any struct instance to be initialized to a `default` value which involves initializing every instance member field to its default value. A `SumSharp` union initialized in such a way is in an invalid, undefined state. Using it may result in exceptions being thrown, or may silently work. **`SumSharp` makes no guarantees about the runtime behavior of default initialized struct unions.** @@ -647,7 +744,9 @@ The custom empty type is required to have a parameterless (default) constructor. ### Disabling value equality -All `SumSharp` union types by default implement the `IEquatable` interface, override the `Object.Equals` member function, and implement `==` and `!=` operators. This allows for value type equality between instances: Two instances of the same union type are equal iff they both hold the same case and their underlying values compare equal using the static `Object.Equals` function. +All `SumSharp` union types by default implement the `IEquatable` interface, override the `Object.Equals` member function, and implement `==` and `!=` operators. This allows for value type equality between instances: Two instances of the same union type are equal iff they both hold the same case and their underlying values compare equal using the static `object.Equals` function. + +`==` and `!=` comparison operators are also generated for each unique type stored by the union, allowing for direct comparisons between a union and a raw value. If you'd rather disable this feature and have reference equality for class type unions add the `[DisableValueEquality]` attribute to your union. _Note that adding this attribute does nothing for record union types because the C\# compiler will always add an `IEquatable` implementation for record types._ diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index c926b87..27f1346 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; @@ -13,6 +14,11 @@ internal class SymbolHandler private static readonly Regex _fieldNameRegex = new(@"[.<>,\s\(\)]+|\[\]", RegexOptions.Compiled); private static readonly Regex _tupleRegex = new(@"^(?:System\.)?ValueTuple<(?.+)>$|^\((?.+)\)$", RegexOptions.Compiled); + 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; } @@ -33,6 +39,10 @@ public abstract class TypeInfo public bool IsTupleType => TupleTypeArgs.Length > 0; + public virtual bool IsAlwaysDisposable => false; + + public virtual bool IsAlwaysAsyncDisposable => false; + public class NonArray(INamedTypeSymbol symbol) : TypeInfo { public override string Name { get; } = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -50,6 +60,10 @@ public class NonArray(INamedTypeSymbol symbol) : TypeInfo public override bool IsInterface => symbol.TypeKind == TypeKind.Interface; public override string[] TupleTypeArgs { get; } = symbol.IsTupleType ? [.. symbol.TypeArguments.Select(t => t.ToDisplayString())] : []; + + public override bool IsAlwaysDisposable => symbol.Interfaces.Any(i => i.Name == "IDisposable"); + + public override bool IsAlwaysAsyncDisposable => symbol.Interfaces.Any(i => i.Name == "IAsyncDisposable"); } public class Array(IArrayTypeSymbol symbol) : TypeInfo @@ -244,6 +258,11 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public CaseData[] UniqueCases { get; } + public Dictionary Net11StructNameMap { get; } + + // Cases grouped by type + public IGrouping[] CaseGroups { get; } + public INamedTypeSymbol[] ContainingTypes; public bool HasGenericContainingTypes => ContainingTypes.Any(type => type.TypeArguments.Length > 0); @@ -278,6 +297,12 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public string FileFriendlyName => $"{Namespace}_{string.Join("_", ContainingTypes.Select(symbol => symbol.Name))}_{_fieldNameRegex.Replace(Name, "_")}"; + public bool IsSealed { get; } + + public bool IsDisposable { get; } + + public bool IsAsyncDisposable { get; } + public SymbolHandler( StringBuilder builder, Compilation compilation, @@ -400,28 +425,84 @@ public SymbolHandler( }) .ToArray(); - var distinctTypes = - Cases - .Where(caseData => caseData.TypeInfo != null) - .Select(caseData => caseData.TypeInfo!.Name) - .Distinct(); + 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.Count() == 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 caseStructTypeArguments = TypeArguments.Intersect(TypeNameParser.ExtractLeafTypes(caseData.TypeInfo.Name)).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() @@ -475,6 +556,12 @@ public SymbolHandler( .GetAttributes() .Where(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, disableNullableSymbol)) .Any(); + + IsSealed = symbol.IsSealed; + + IsDisposable = Cases.Any(caseData => caseData.TypeInfo is not null && (caseData.TypeInfo.IsAlwaysDisposable || caseData.TypeInfo.IsGeneric)); + + IsAsyncDisposable = Cases.Any(caseData => caseData.TypeInfo is not null && (caseData.TypeInfo.IsAlwaysAsyncDisposable || caseData.TypeInfo.IsGeneric)); } private bool GetStoreAsObject(int storageStrategy, int storageMode, TypeInfo typeInfo) @@ -588,6 +675,8 @@ public string Emit() EmitCaseConstructors(); + EmitNativeUnion(); + EmitAs(); EmitIs(); @@ -607,6 +696,16 @@ public string Emit() EmitToString(); + if (IsDisposable) + { + EmitDispose(); + } + + if (IsAsyncDisposable) + { + EmitDisposeAsync(); + } + if (EnableStandardJsonSerialization) { EmitStandardJsonConverter(); @@ -694,8 +793,63 @@ private void EmitFieldsAndConstructor() fieldNameTypeMap[caseData.FieldType!] = caseData.FieldName!; } + string interfaces = ": "; + + if (!DisableValueEquality) + { + interfaces += $@" + System.IEquatable<{Name}>"; + } + if (IsDisposable) + { + interfaces += @", + System.IDisposable"; + } + if (IsAsyncDisposable) + { + 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 + {Accessibility} 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} + {Accessibility} readonly record struct {Net11StructNameMap[caseData].NameWithTypeArgs}({caseData.TypeInfo.Name} Value) {Net11StructNameMap[caseData].Constraints};"); + } + } + Builder.Append($@" -{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(DisableValueEquality ? "" : $" : System.IEquatable<{Name}>")} +[System.Runtime.CompilerServices.Union] +#endif +{GeneratedCodeAttribute} +{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name} {interfaces} {{"); foreach (var field in fieldNameTypeMap) @@ -704,6 +858,12 @@ private void EmitFieldsAndConstructor() private {field.Key} {field.Value} = default;"); } + if (IsDisposable) + { + Builder.Append(@" + private bool _disposed = false;"); + } + Builder.AppendLine($@" ///The zero-based index of the case held by the discriminated union @@ -736,8 +896,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) { @@ -763,7 +922,7 @@ static void CheckUnmanagedStorage() where TUnmanaged__ : unmanaged var _ = new StandardJsonConverter();"); } - Builder.AppendLine(@" + Builder.AppendLine(@" }"); } @@ -774,6 +933,7 @@ public void EmitUnmanagedStorageSize() public static int UnmanagedStorageSize => _unmanagedStorageSize;"); } + public void EmitEquals() { Builder.Append($@" @@ -781,6 +941,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 @@ -843,6 +1004,118 @@ public override int GetHashCode() ///Compares two {XMLEscapedName} instances for inequality using System.IEquatable<{XMLEscapedName}>.Equals public static bool operator!=({Name} left, {Name} right) => !left.Equals(right);"); + + bool disableUnderlyingValueEquality = EnableStandardJsonSerialization && !AddJsonConverterAttribute; + + if (disableUnderlyingValueEquality) + { + Builder.AppendLine(@" +// These equality operators interfere with JSON source generation in .NET 8 +#if NET9_0_OR_GREATER"); + } + + 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) + {{ + switch (left.Index) + {{"); + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + continue; + } + + if (caseData.TypeInfo.IsGeneric || type.IsGeneric) + { + 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; + + 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; + + 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($@" + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; + + var castedLeftValue = System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue); + + 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; + + ///Compares a {XMLEscapedName} with a for inequality using on the underlying value + public static bool operator!=({Name} left, {type.Name} right) => !(left == right); + + ///Compares a with a {XMLEscapedName} for inequality using on the underlying value + public static bool operator!=({type.Name} left, {Name} right) => !(right == left);"); + } + + if (disableUnderlyingValueEquality) + { + Builder.AppendLine(@" +#endif"); + } } private void EmitCaseConstructors() { @@ -920,6 +1193,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) @@ -1003,6 +1367,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) @@ -1328,12 +1693,126 @@ public override string ToString() "); } + private void EmitDispose() + { + Builder.Append($@" + public void Dispose() + {{ + Dispose(true); + + System.GC.SuppressFinalize(this); + }} + + {(IsSealed ? "private" : "protected virtual")} void Dispose(bool disposing) + {{ + if (_disposed) + {{ + return; + }} + + if (disposing) + {{ + switch (Index) + {{"); + + foreach (var caseData in Cases) + { + var disposeExpression = ""; + + if (caseData.TypeInfo is not null) + { + if (caseData.TypeInfo.IsAlwaysDisposable) + { + disposeExpression = $"As{caseData.Name}Unsafe.Dispose();"; + } + else if (caseData.TypeInfo.IsGeneric) + { + disposeExpression = $@" + if (As{caseData.Name}Unsafe is System.IDisposable _disposable{caseData.Name}) + {{ + _disposable{caseData.Name}.Dispose(); + }}"; + } + } + + Builder.Append($@" + case {caseData.Index}: + {disposeExpression} + break;"); + } + + Builder.AppendLine(@" + } + } + + _disposed = true; + }"); + } + + private void EmitDisposeAsync() + { + Builder.Append($@" + public async ValueTask DisposeAsync() + {{ + await DisposeAsyncCore().ConfigureAwait(false); + + {(IsDisposable ? "Dispose(false);" : "")} + System.GC.SuppressFinalize(this); + }} + + {(IsSealed ? "private" : "protected virtual")} async ValueTask DisposeAsyncCore() + {{ + switch (Index) + {{"); + + foreach (var caseData in Cases) + { + var disposeExpression = ""; + + if (caseData.TypeInfo is not null) + { + if (caseData.TypeInfo.IsAlwaysAsyncDisposable) + { + disposeExpression = $"await As{caseData.Name}Unsafe.DisposeAsync().ConfigureAwait(false);"; + } + else if (caseData.TypeInfo.IsAlwaysDisposable) + { + disposeExpression = $"As{caseData.Name}Unsafe.Dispose();"; + } + else if (caseData.TypeInfo.IsGeneric) + { + disposeExpression = $@" + if (As{caseData.Name}Unsafe is System.IAsyncDisposable _asyncDisposable{caseData.Name}) + {{ + await _asyncDisposable{caseData.Name}.DisposeAsync().ConfigureAwait(false); + }} + else if (As{caseData.Name}Unsafe is System.IDisposable _disposable{caseData.Name}) + {{ + _disposable{caseData.Name}.Dispose(); + }}"; + } + } + + Builder.Append($@" + case {caseData.Index}: + {disposeExpression} + break;"); + } + + Builder.AppendLine(@" + } + }"); + } + 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 : "")} + {(UsingAOTCompilation ? IL3050SupressAttribute : "")} public override {Name}{NullableIfRef} Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) {{ if (reader.TokenType == System.Text.Json.JsonTokenType.Null) @@ -1387,6 +1866,8 @@ public partial class StandardJsonConverter : System.Text.Json.Serialization.Json return ret; }} + {(UsingAOTCompilation ? IL2026SupressAttribute : "")} + {(UsingAOTCompilation ? IL3050SupressAttribute : "")} public override void Write(System.Text.Json.Utf8JsonWriter writer, {Name}{NullableIfRef} value, System.Text.Json.JsonSerializerOptions options) {{"); @@ -1442,6 +1923,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) @@ -1557,6 +2039,7 @@ private void EmitEndClassDeclaration() private void EmitStaticClass() { Builder.Append($@" +{GeneratedCodeAttribute} {Accessibility} static partial class {NameWithoutTypeArguments} {{"); } @@ -1567,6 +2050,8 @@ 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) @@ -1589,45 +2074,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/SumSharp/Internal/Box.cs b/SumSharp/Internal/Box.cs index 01f2b6b..40c2735 100644 --- a/SumSharp/Internal/Box.cs +++ b/SumSharp/Internal/Box.cs @@ -18,9 +18,8 @@ public override bool Equals(object obj) { if (obj is null) return false; if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; - return Equals(System.Runtime.CompilerServices.Unsafe.As>(obj)); + return Equals(obj as Box); } public override int GetHashCode() => Value.GetHashCode(); diff --git a/Tests.AOT/Tests.AOT.csproj b/Tests.AOT/Tests.AOT.csproj index 5be5756..53a68de 100644 --- a/Tests.AOT/Tests.AOT.csproj +++ b/Tests.AOT/Tests.AOT.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 disable enable true 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 + }); + } +} diff --git a/Tests/Dispose.cs b/Tests/Dispose.cs new file mode 100644 index 0000000..207a8a3 --- /dev/null +++ b/Tests/Dispose.cs @@ -0,0 +1,244 @@ +namespace Tests; + +using SumSharp; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +public partial class Dispose +{ + class Disposable(Action onDispose) : IDisposable + { + public void Dispose() => onDispose(); + } + + class AsyncDisposable(Action onDispose) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + onDispose(); + + return ValueTask.CompletedTask; + } + } + + class DisposableAndAsyncDisposable(Action onDispose) : IDisposable, IAsyncDisposable + { + public void Dispose() => onDispose(); + + public ValueTask DisposeAsync() + { + onDispose(); + + return ValueTask.CompletedTask; + } + } + + + [UnionCase("Case0", typeof(string))] + [UnionCase("Case1", typeof(Disposable))] + partial class StringOrDisposable + { + + } + + [UnionCase("Case0", "T")] + [UnionCase("Case1", "U")] + partial class GenericDisposable + { + + } + + [UnionCase("Case0", typeof(string))] + [UnionCase("Case1", typeof(AsyncDisposable))] + partial class StringOrAsyncDisposable + { + + } + + [UnionCase("Case0", typeof(string))] + [UnionCase("Case1", typeof(DisposableAndAsyncDisposable))] + partial struct StringOrDisposableAndAsyncDisposable + { + + } + + [UnionCase("Case0", typeof(Disposable))] + [UnionCase("Case1", typeof(AsyncDisposable))] + sealed partial class DisposableOrAsyncDisposable + { + + } + + [Fact] + public void NonGenericDispose() + { + bool disposed = false; + + { + using StringOrDisposable value = "string"; + } + + Assert.False(disposed); + + { + using StringOrDisposable value = new Disposable(() => disposed = true); + + Assert.False(disposed); + } + + Assert.True(disposed); + } + + [Fact] + public void GenericDispose() + { + bool disposed = false; + + { + using GenericDisposable value = 1; + } + + Assert.False(disposed); + + { + using GenericDisposable value = new Disposable(() => disposed = true); + + Assert.False(disposed); + } + + Assert.True(disposed); + } + + [Fact] + public async Task NonGenericAsyncDispose() + { + bool disposed = false; + + { + await using StringOrAsyncDisposable value = "string"; + } + + Assert.False(disposed); + + { + await using StringOrAsyncDisposable value = new AsyncDisposable(() => disposed = true); + + Assert.False(disposed); + } + + Assert.True(disposed); + } + + [Fact] + public async Task GenericAsyncDispose() + { + bool disposed = false; + + { + await using GenericDisposable value = 0.0; + } + + Assert.False(disposed); + + { + await using GenericDisposable value = new AsyncDisposable(() => disposed = true); + + Assert.False(disposed); + } + + Assert.True(disposed); + } + + [Fact] + public async Task GenericDisposeAndAsyncDispose() + { + bool disposed = false; + + { + using GenericDisposable value = new Disposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + + { + using GenericDisposable value = new AsyncDisposable(() => disposed = true); + } + + Assert.False(disposed); + + { + await using GenericDisposable value = new Disposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + + { + await using GenericDisposable value = new AsyncDisposable(() => disposed = true); + } + + Assert.True(disposed); + } + + [Fact] + public async Task DisposableOrAsyncDisposableDispose() + { + bool disposed = false; + + { + using DisposableOrAsyncDisposable value = new Disposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + + { + await using DisposableOrAsyncDisposable value = new Disposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + + { + using DisposableOrAsyncDisposable value = new AsyncDisposable(() => disposed = true); + } + + Assert.False(disposed); + + { + await using DisposableOrAsyncDisposable value = new AsyncDisposable(() => disposed = true); + } + + Assert.True(disposed); + } + + [Fact] + public async Task DisposableAndAsyncDisposableDispose() + { + bool disposed = false; + + { + using StringOrDisposableAndAsyncDisposable value = new DisposableAndAsyncDisposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + + { + await using StringOrDisposableAndAsyncDisposable value = new DisposableAndAsyncDisposable(() => disposed = true); + } + + Assert.True(disposed); + + disposed = false; + } +} \ No newline at end of file diff --git a/Tests/Equals.cs b/Tests/Equals.cs index 145c6ec..490d4c3 100644 --- a/Tests/Equals.cs +++ b/Tests/Equals.cs @@ -38,6 +38,16 @@ partial record struct StringOrDoubleRecordStruct } + [UnionCase("Case0", typeof(string))] + [UnionCase("Case1", typeof(double))] + [UnionCase("Case2", typeof(string))] + [UnionCase("Case3", typeof(double))] + [UnionCase("Case4", "T")] + partial class StringOrDoubleExtended + { + + } + [Fact] public void ValueEquality() { @@ -85,4 +95,56 @@ public void RecordStructEquality() Assert.True(StringOrDoubleRecordStruct.Case0("") is IEquatable); } + + [Fact] + public void UnderlyingValueEquality() + { + Assert.True("abc" == StringOrDoubleExtended.Case0("abc")); + Assert.True("efg" != StringOrDoubleExtended.Case0("abc")); + Assert.True(3.45 != StringOrDoubleExtended.Case0("abc")); + + Assert.True(StringOrDoubleExtended.Case0("abc") == "abc"); + Assert.True(StringOrDoubleExtended.Case0("abc") != "efg"); + Assert.True(StringOrDoubleExtended.Case0("abc") != 3.45); + + Assert.True(StringOrDoubleExtended.Case1(3.45) == 3.45); + Assert.True(StringOrDoubleExtended.Case1(3.45) != 3.46); + Assert.True(StringOrDoubleExtended.Case1(3.45) != "abc"); + + Assert.True(3.45 == StringOrDoubleExtended.Case1(3.45)); + Assert.True(3.46 != StringOrDoubleExtended.Case1(3.45)); + Assert.True("abc" != StringOrDoubleExtended.Case1(3.45)); + + Assert.True("abc" == StringOrDoubleExtended.Case2("abc")); + Assert.True("efg" != StringOrDoubleExtended.Case2("abc")); + Assert.True(3.45 != StringOrDoubleExtended.Case2("abc")); + + Assert.True(StringOrDoubleExtended.Case2("abc") == "abc"); + Assert.True(StringOrDoubleExtended.Case2("abc") != "efg"); + Assert.True(StringOrDoubleExtended.Case2("abc") != 3.45); + + Assert.True(StringOrDoubleExtended.Case3(3.45) == 3.45); + Assert.True(StringOrDoubleExtended.Case3(3.45) != 3.46); + Assert.True(StringOrDoubleExtended.Case3(3.45) != "abc"); + + Assert.True(3.45 == StringOrDoubleExtended.Case3(3.45)); + Assert.True(3.46 != StringOrDoubleExtended.Case3(3.45)); + Assert.True("abc" != StringOrDoubleExtended.Case3(3.45)); + + Assert.True("abc" == StringOrDoubleExtended.Case4("abc")); + Assert.True("efg" != StringOrDoubleExtended.Case4("abc")); + Assert.True(3.45 != StringOrDoubleExtended.Case4("abc")); + + Assert.True(StringOrDoubleExtended.Case4("abc") == "abc"); + Assert.True(StringOrDoubleExtended.Case4("abc") != "efg"); + Assert.True(StringOrDoubleExtended.Case4("abc") != 3.45); + + Assert.True(5 == StringOrDoubleExtended.Case4(5)); + Assert.True("abc" != StringOrDoubleExtended.Case4(5)); + Assert.True(3.45 != StringOrDoubleExtended.Case4(5)); + + Assert.True(StringOrDoubleExtended.Case4(5) == 5); + Assert.True(StringOrDoubleExtended.Case4(5) != "abc"); + Assert.True(StringOrDoubleExtended.Case4(5) != 3.45); + } } \ No newline at end of file diff --git a/Tests/Storage.cs b/Tests/Storage.cs index 51979d2..0a5df93 100644 --- a/Tests/Storage.cs +++ b/Tests/Storage.cs @@ -292,7 +292,9 @@ public void InsufficientStorageThrows() public void GenericUnmanagedTypeProperties() { Assert.Equal(typeof(SumSharp.Internal.Generated.Tests_Storage_GenericUnmanagedType_T_.UnmanagedStorage), typeof(GenericUnmanagedType).GetField("_unmanagedStorage", BindingFlags.NonPublic | BindingFlags.Instance)?.FieldType); - Assert.Equal(2, typeof(GenericUnmanagedType).GetFields(BindingFlags.NonPublic | BindingFlags.Instance).Length); + + // One field for unmanaged storgae, one field for the index, one field for _disposed + Assert.Equal(3, typeof(GenericUnmanagedType).GetFields(BindingFlags.NonPublic | BindingFlags.Instance).Length); } [Fact] diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 08e8293..8d3b711 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 disable enable