diff --git a/Directory.Packages.props b/Directory.Packages.props index cbe66772..0ae2bf37 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/Funcky.Xunit.v3.Test/Funcky.Xunit.v3.Test.csproj b/Funcky.Xunit.v3.Test/Funcky.Xunit.v3.Test.csproj index fce7830d..d2e42bb3 100644 --- a/Funcky.Xunit.v3.Test/Funcky.Xunit.v3.Test.csproj +++ b/Funcky.Xunit.v3.Test/Funcky.Xunit.v3.Test.csproj @@ -1,4 +1,4 @@ - + net9.0 preview @@ -24,4 +24,5 @@ + diff --git a/Funcky.Xunit.v3.Test/Serializers/EitherSerializerTest.cs b/Funcky.Xunit.v3.Test/Serializers/EitherSerializerTest.cs new file mode 100644 index 00000000..27cd7f4b --- /dev/null +++ b/Funcky.Xunit.v3.Test/Serializers/EitherSerializerTest.cs @@ -0,0 +1,38 @@ +using Funcky.Xunit.Serializers; + +namespace Funcky.Xunit.Test; + +public sealed class EitherSerializerTest +{ + private static readonly EitherSerializer Serializer = new(); + + [Theory] + [MemberData(nameof(EitherProvider))] + public void RoundtripsEitherValues(IEither either) + { + Assert.True(Serializer.IsSerializable(either.GetType(), either, out _)); + var serialized = Serializer.Serialize(either); + var deserialized = Serializer.Deserialize(either.GetType(), serialized); + Assert.Equal(either, deserialized); + } + + public static TheoryData EitherProvider() + => new((IEnumerable)[ + Either.Return(10), + Either.Return(20), + Either.Left("foo"), + Either.Left("bar"), + Either.Left("bar"), + Either.Right(42), + Either.Left(42), + ]); + + [Fact] + public void EitherSideOfNonSerializableTypeIsNotSerializable() + { + Assert.False(Serializer.IsSerializable(typeof(Either), Either.Left(new NonSerializable()), out _)); + Assert.False(Serializer.IsSerializable(typeof(Either), Either.Right(new NonSerializable()), out _)); + } + + public sealed class NonSerializable; +} diff --git a/Funcky.Xunit.v3.Test/Serializers/OptionSerializerTest.cs b/Funcky.Xunit.v3.Test/Serializers/OptionSerializerTest.cs new file mode 100644 index 00000000..25fadc4d --- /dev/null +++ b/Funcky.Xunit.v3.Test/Serializers/OptionSerializerTest.cs @@ -0,0 +1,45 @@ +using Funcky.Xunit.Serializers; + +namespace Funcky.Xunit.Test; + +public sealed class OptionSerializerTest +{ + private static readonly OptionSerializer Serializer = new(); + + [Theory] + [MemberData(nameof(OptionProvider))] + public void RoundtripsOptionValues(Option option) + { + var serialized = Serializer.Serialize(option); + var deserialized = Serializer.Deserialize(option.GetType(), serialized); + Assert.Equal(option, deserialized); + } + + public static TheoryData> OptionProvider() + => [ + Option.Some(10), + Option.Some(20), + Option.None, + ]; + + [Fact] + public void OptionOfSerializableTypeIsSerializable() + { + Assert.True(Serializer.IsSerializable(typeof(Option), Option.Some(10), out _)); + Assert.True(Serializer.IsSerializable(typeof(Option), Option.None, out _)); + } + + [Fact] + public void OptionOfNonSerializableTypeIsNotSerializable() + { + Assert.False(Serializer.IsSerializable(typeof(Option), Option.Some(new NonSerializable()), out _)); + } + + [Fact] + public void NoneIsAlwaysSerializable() + { + Assert.True(Serializer.IsSerializable(typeof(Option), Option.None, out _)); + } + + public sealed class NonSerializable; +} diff --git a/Funcky.Xunit.v3.Test/Serializers/UnitSerializerTest.cs b/Funcky.Xunit.v3.Test/Serializers/UnitSerializerTest.cs new file mode 100644 index 00000000..d83291ab --- /dev/null +++ b/Funcky.Xunit.v3.Test/Serializers/UnitSerializerTest.cs @@ -0,0 +1,22 @@ +using Funcky.Xunit.Serializers; + +namespace Funcky.Xunit.Test; + +public sealed class UnitSerializerTest +{ + private static readonly UnitSerializer Serializer = new(); + + [Fact] + public void RoundtripsUnit() + { + var serialized = Serializer.Serialize(Unit.Value); + var deserialized = Serializer.Deserialize(typeof(Unit), serialized); + Assert.Equal(Unit.Value, deserialized); + } + + [Fact] + public void UnitIsSerializable() + { + Assert.True(Serializer.IsSerializable(typeof(Unit), Unit.Value, out _)); + } +} diff --git a/Funcky.Xunit.v3/Funcky.Xunit.v3.csproj b/Funcky.Xunit.v3/Funcky.Xunit.v3.csproj index 000a226b..abbbf01c 100644 --- a/Funcky.Xunit.v3/Funcky.Xunit.v3.csproj +++ b/Funcky.Xunit.v3/Funcky.Xunit.v3.csproj @@ -19,12 +19,19 @@ $(DefineConstants);STACK_TRACE_HIDDEN_SUPPORTED + + + + + + + diff --git a/Funcky.Xunit.v3/PublicAPI.Unshipped.txt b/Funcky.Xunit.v3/PublicAPI.Unshipped.txt index f7b41058..bdd3a6ff 100644 --- a/Funcky.Xunit.v3/PublicAPI.Unshipped.txt +++ b/Funcky.Xunit.v3/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +Funcky.Xunit.RegisterEitherSerializerAttribute +Funcky.Xunit.RegisterEitherSerializerAttribute.RegisterEitherSerializerAttribute() -> void +Funcky.Xunit.RegisterOptionSerializerAttribute +Funcky.Xunit.RegisterOptionSerializerAttribute.RegisterOptionSerializerAttribute() -> void +Funcky.Xunit.RegisterUnitSerializerAttribute +Funcky.Xunit.RegisterUnitSerializerAttribute.RegisterUnitSerializerAttribute() -> void static Funcky.FunctionalAssert.Some(TItem expectedValue, Funcky.Monads.Option option) -> void static Funcky.FunctionalAssert.Some(Funcky.Monads.Option option) -> TItem static Funcky.FunctionalAssert.Right(TRight expectedRight, Funcky.Monads.Either either) -> void diff --git a/Funcky.Xunit.v3/RegisterEitherSerializerAttribute.cs b/Funcky.Xunit.v3/RegisterEitherSerializerAttribute.cs new file mode 100644 index 00000000..52af2d59 --- /dev/null +++ b/Funcky.Xunit.v3/RegisterEitherSerializerAttribute.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using Funcky.Xunit.Serializers; +using Xunit.Sdk; + +namespace Funcky.Xunit; + +[AttributeUsage(AttributeTargets.Assembly)] +[EditorBrowsable(EditorBrowsableState.Advanced)] +public sealed class RegisterEitherSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute +{ + Type IRegisterXunitSerializerAttribute.SerializerType => typeof(EitherSerializer); + + Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(IEither)]; +} diff --git a/Funcky.Xunit.v3/RegisterOptionSerializerAttribute.cs b/Funcky.Xunit.v3/RegisterOptionSerializerAttribute.cs new file mode 100644 index 00000000..66786df5 --- /dev/null +++ b/Funcky.Xunit.v3/RegisterOptionSerializerAttribute.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using Funcky.Xunit.Serializers; +using Xunit.Sdk; + +namespace Funcky.Xunit; + +[AttributeUsage(AttributeTargets.Assembly)] +[EditorBrowsable(EditorBrowsableState.Advanced)] +public sealed class RegisterOptionSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute +{ + Type IRegisterXunitSerializerAttribute.SerializerType => typeof(OptionSerializer); + + Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(IOption)]; +} diff --git a/Funcky.Xunit.v3/RegisterUnitSerializerAttribute.cs b/Funcky.Xunit.v3/RegisterUnitSerializerAttribute.cs new file mode 100644 index 00000000..8a101b03 --- /dev/null +++ b/Funcky.Xunit.v3/RegisterUnitSerializerAttribute.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using Funcky.Xunit.Serializers; +using Xunit.Sdk; + +namespace Funcky.Xunit; + +[AttributeUsage(AttributeTargets.Assembly)] +[EditorBrowsable(EditorBrowsableState.Advanced)] +public sealed class RegisterUnitSerializerAttribute : Attribute, IRegisterXunitSerializerAttribute +{ + Type IRegisterXunitSerializerAttribute.SerializerType => typeof(UnitSerializer); + + Type[] IRegisterXunitSerializerAttribute.SupportedTypesForSerialization => [typeof(Unit)]; +} diff --git a/Funcky.Xunit.v3/Serializers/EitherSerializer.cs b/Funcky.Xunit.v3/Serializers/EitherSerializer.cs new file mode 100644 index 00000000..029b2595 --- /dev/null +++ b/Funcky.Xunit.v3/Serializers/EitherSerializer.cs @@ -0,0 +1,86 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Xunit.Sdk; +using static Funcky.Discard; + +namespace Funcky.Xunit.Serializers; + +internal sealed class EitherSerializer : IXunitSerializer +{ + private static readonly MethodInfo GenericDeserialize + = typeof(EitherSerializer).GetMethod(nameof(Deserialize), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic deserialize method not found"); + + private static readonly MethodInfo GenericIsSerializable + = typeof(EitherSerializer).GetMethod(nameof(IsSerializable), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic IsSerializable method not found"); + + private static readonly MethodInfo GenericSerialize + = typeof(EitherSerializer).GetMethod(nameof(Serialize), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic serialize method not found"); + + public object Deserialize(Type type, string serializedValue) + { + var (leftType, rightType) = GetLeftAndRightTypeOrThrow(type); + return GenericDeserialize.MakeGenericMethod(leftType, rightType).Invoke(null, [serializedValue])!; + } + + public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason) + { + failureReason = string.Empty; + return GetLeftAndRightType(type) is [var (leftType, rightType)] + && (bool)GenericIsSerializable.MakeGenericMethod(leftType, rightType).Invoke(null, [leftType, rightType, value])!; + } + + public string Serialize(object value) + { + var (leftType, rightType) = GetLeftAndRightTypeOrThrow(value.GetType()); + return (string)GenericSerialize.MakeGenericMethod(leftType, rightType).Invoke(null, [value])!; + } + + private static (Type Left, Type Right) GetLeftAndRightTypeOrThrow(Type eitherType) + => GetLeftAndRightType(eitherType).GetOrElse(() => throw new InvalidOperationException($"{eitherType} is not an Either")); + + private static Option<(Type Left, Type Right)> GetLeftAndRightType(Type eitherType) + => eitherType.IsGenericType && eitherType.GetGenericTypeDefinition() == typeof(Either<,>) + ? (eitherType.GenericTypeArguments[0], eitherType.GenericTypeArguments[1]) + : Option<(Type, Type)>.None; + + private static object Deserialize(string serializedValue) + where TLeft : notnull + where TRight : notnull + => __ switch + { + _ when serializedValue.StripPrefix(Tag.Left) is [var rest] + => Either.Left(SerializationHelper.Instance.Deserialize(rest)!), + _ when serializedValue.StripPrefix(Tag.Right) is [var rest] + => Either.Right(SerializationHelper.Instance.Deserialize(rest)!), + _ => throw new FormatException($"'{serializedValue}' is not a valid either value"), + }; + + private static bool IsSerializable(Type leftType, Type rightType, object? value) + where TLeft : notnull + where TRight : notnull + { + var either = (Either)(value ?? throw new InvalidOperationException("Either cannot be null")); + return either.Match( + left: left => SerializationHelper.Instance.IsSerializable(left, leftType), + right: right => SerializationHelper.Instance.IsSerializable(right, rightType)); + } + + private static string Serialize(object obj) + where TLeft : notnull + where TRight : notnull + { + var either = (Either)obj; + return either.Match( + left: left => $"{Tag.Left}{SerializationHelper.Instance.Serialize(left)}", + right: right => $"{Tag.Right}{SerializationHelper.Instance.Serialize(right)}"); + } + + private static class Tag + { + public const string Left = "L:"; + public const string Right = "R:"; + } +} diff --git a/Funcky.Xunit.v3/Serializers/OptionSerializer.cs b/Funcky.Xunit.v3/Serializers/OptionSerializer.cs new file mode 100644 index 00000000..e779468d --- /dev/null +++ b/Funcky.Xunit.v3/Serializers/OptionSerializer.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Xunit.Sdk; +using static Funcky.Discard; + +namespace Funcky.Xunit.Serializers; + +internal sealed class OptionSerializer : IXunitSerializer +{ + private static readonly MethodInfo GenericDeserialize + = typeof(OptionSerializer).GetMethod(nameof(Deserialize), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic deserialize method not found"); + + private static readonly MethodInfo GenericIsSerializable + = typeof(OptionSerializer).GetMethod(nameof(IsSerializable), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic IsSerializable method not found"); + + private static readonly MethodInfo GenericSerialize + = typeof(OptionSerializer).GetMethod(nameof(Serialize), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new MissingMethodException("Generic serialize method not found"); + + public object Deserialize(Type type, string serializedValue) + { + var itemType = GetItemTypeOrThrow(type); + return GenericDeserialize.MakeGenericMethod(itemType).Invoke(null, [serializedValue])!; + } + + public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason) + { + failureReason = string.Empty; + return GetItemType(type) is [var itemType] + && (bool)GenericIsSerializable.MakeGenericMethod(itemType).Invoke(null, [itemType, value])!; + } + + public string Serialize(object value) + { + var itemType = GetItemTypeOrThrow(value.GetType()); + return (string)GenericSerialize.MakeGenericMethod(itemType).Invoke(null, [value])!; + } + + private static Type GetItemTypeOrThrow(Type eitherType) + => GetItemType(eitherType).GetOrElse(() => throw new InvalidOperationException($"{eitherType} is not an Option")); + + private static Option GetItemType(Type optionType) + => optionType.IsGenericType && optionType.GetGenericTypeDefinition() == typeof(Option<>) + ? optionType.GenericTypeArguments.First() + : Option.None; + + private static object Deserialize(string serializedValue) + where TItem : notnull + => __ switch + { + _ when serializedValue == Tag.None => Option.None, + _ when serializedValue.StripPrefix(Tag.Some) is [var rest] => Option.Some(SerializationHelper.Instance.Deserialize(rest)!), + _ => throw new FormatException($"'{serializedValue}' is not a valid option value"), + }; + + private static bool IsSerializable(Type itemType, object? value) + where TItem : notnull + { + var option = (Option)(value ?? throw new InvalidOperationException("TODO")); + return option.Match(none: true, some: item => SerializationHelper.Instance.IsSerializable(item, itemType)); + } + + private static string Serialize(object obj) + where TItem : notnull + { + var option = (Option)obj; + return option.Match( + none: () => Tag.None, + some: item => $"{Tag.Some}{SerializationHelper.Instance.Serialize(item)}"); + } + + private static class Tag + { + public const string None = "N"; + public const string Some = "S:"; + } +} diff --git a/Funcky.Xunit.v3/Serializers/StringExtensions.cs b/Funcky.Xunit.v3/Serializers/StringExtensions.cs new file mode 100644 index 00000000..0fd04823 --- /dev/null +++ b/Funcky.Xunit.v3/Serializers/StringExtensions.cs @@ -0,0 +1,9 @@ +namespace Funcky.Xunit.Serializers; + +internal static class StringExtensions +{ + public static Option StripPrefix(this string s, string prefix) + => s.StartsWith(prefix) + ? s[prefix.Length..] + : Option.None; +} diff --git a/Funcky.Xunit.v3/Serializers/UnitSerializer.cs b/Funcky.Xunit.v3/Serializers/UnitSerializer.cs new file mode 100644 index 00000000..4047de8e --- /dev/null +++ b/Funcky.Xunit.v3/Serializers/UnitSerializer.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using Xunit.Sdk; + +namespace Funcky.Xunit.Serializers; + +internal sealed class UnitSerializer : IXunitSerializer +{ + public object Deserialize(Type type, string serializedValue) => Unit.Value; + + public bool IsSerializable(Type type, object? value, [NotNullWhen(false)] out string? failureReason) + { + failureReason = string.Empty; + return type == typeof(Unit); + } + + public string Serialize(object value) => string.Empty; +} diff --git a/Funcky.Xunit.v3/build/Funcky.Xunit.v3.targets b/Funcky.Xunit.v3/build/Funcky.Xunit.v3.targets new file mode 100644 index 00000000..1829f164 --- /dev/null +++ b/Funcky.Xunit.v3/build/Funcky.Xunit.v3.targets @@ -0,0 +1,11 @@ + + + + true + + + + + + + diff --git a/Funcky/Monads/Either/Either.Core.cs b/Funcky/Monads/Either/Either.Core.cs index dc0b06c3..25d1d537 100644 --- a/Funcky/Monads/Either/Either.Core.cs +++ b/Funcky/Monads/Either/Either.Core.cs @@ -8,7 +8,7 @@ namespace Funcky.Monads; /// Any attempt to perform actions on such a value will throw a . /// [NonDefaultable] -public readonly partial struct Either : IEquatable> +public readonly partial struct Either : IEquatable>, IEither where TLeft : notnull where TRight : notnull { @@ -107,6 +107,10 @@ public override string ToString() left: static left => $"Left({left})", right: static right => $"Right({right})"); + void IEither.InternalImplementationOnly() + { + } + [Pure] [UseWithArgumentNames] internal TMatchResult Match(Func uninitialized, Func left, Func right) diff --git a/Funcky/Monads/Either/IEither.cs b/Funcky/Monads/Either/IEither.cs new file mode 100644 index 00000000..eab3e70d --- /dev/null +++ b/Funcky/Monads/Either/IEither.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace Funcky.Monads; + +/// Marker interface implemented for . +[EditorBrowsable(EditorBrowsableState.Advanced)] +public interface IEither +{ + [EditorBrowsable(EditorBrowsableState.Never)] + internal void InternalImplementationOnly(); +} diff --git a/Funcky/Monads/Option/IOption.cs b/Funcky/Monads/Option/IOption.cs new file mode 100644 index 00000000..75a3528a --- /dev/null +++ b/Funcky/Monads/Option/IOption.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace Funcky.Monads; + +/// Marker interface implemented for . +[EditorBrowsable(EditorBrowsableState.Advanced)] +public interface IOption +{ + [EditorBrowsable(EditorBrowsableState.Never)] + internal void InternalImplementationOnly(); +} diff --git a/Funcky/Monads/Option/Option.Core.cs b/Funcky/Monads/Option/Option.Core.cs index 3772644a..ec1e29f7 100644 --- a/Funcky/Monads/Option/Option.Core.cs +++ b/Funcky/Monads/Option/Option.Core.cs @@ -4,7 +4,7 @@ namespace Funcky.Monads; -public readonly partial struct Option +public readonly partial struct Option : IOption where TItem : notnull { private readonly bool _hasItem; @@ -88,6 +88,10 @@ public override string ToString() => Match( none: "None", some: value => $"Some({value})"); + + void IOption.InternalImplementationOnly() + { + } } public static partial class Option diff --git a/Funcky/PublicAPI.Unshipped.txt b/Funcky/PublicAPI.Unshipped.txt index 9d0d9458..d4905778 100644 --- a/Funcky/PublicAPI.Unshipped.txt +++ b/Funcky/PublicAPI.Unshipped.txt @@ -1,6 +1,8 @@ #nullable enable Funcky.Extensions.JsonSerializerOptionsExtensions Funcky.Extensions.OrderedDictionaryExtensions +Funcky.Monads.IEither +Funcky.Monads.IOption static Funcky.Extensions.DictionaryExtensions.RemoveOrNone(this System.Collections.Generic.IDictionary! dictionary, TKey key) -> Funcky.Monads.Option static Funcky.Extensions.FuncExtensions.Apply(this System.Func! func, Funcky.Unit p1, Funcky.Unit p2, Funcky.Unit p3, Funcky.Unit p4, T5 p5) -> System.Func! static Funcky.Extensions.FuncExtensions.Apply(this System.Func! func, Funcky.Unit p1, Funcky.Unit p2, Funcky.Unit p3, T4 p4, Funcky.Unit p5) -> System.Func! diff --git a/SemanticVersioning.targets b/SemanticVersioning.targets index 18d6a4f2..341027f3 100644 --- a/SemanticVersioning.targets +++ b/SemanticVersioning.targets @@ -1,54 +1,3 @@ - - - <_NuGetVersioningAssemblyFile Condition="'$(MSBuildRuntimeType)' == 'Core'">$(MSBuildToolsPath)/Sdks/NuGet.Build.Tasks.Pack/CoreCLR/NuGet.Versioning.dll - <_NuGetVersioningAssemblyFile Condition="'$(MSBuildRuntimeType)' != 'Core'">$(MSBuildToolsPath)/Sdks/NuGet.Build.Tasks.Pack/Desktop/NuGet.Versioning.dll - <_NuGetVersioningAssemblyFile Condition="!$([System.IO.Path]::Exists('$(_NuGetVersioningAssemblyFile)'))">$(MSBuildToolsPath)/NuGet.Versioning.dll - - - - - - - - - - - $"[{version}]", - // We use Cargo's convention for 0.x versions i.e. minor = breaking, patch = feature or patch. - { Major: 0, Minor: var minor, Patch: var patch } => $"[0.{minor}.{patch}, 0.{minor + 1})", - // 1.x versions follow regular SemVer rules. - { Major: var major, Minor: var minor, Patch: var patch } => $"[{major}.{minor}.{patch}, {major + 1})", - }; - projectReference.SetMetadata("ProjectVersion", range); - } - - ProjectReferencesWithExactVersions = ProjectReferencesWithVersions; - ]]> - - - - - - - - - <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" /> - <_ProjectReferencesWithVersions Include="@(_ProjectReferencesWithExactVersions)" /> - -