diff --git a/Directory.Build.props b/Directory.Build.props index be8fc71..c08cbcf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,36 +1,31 @@ - - - - enable - 1.11.4-preview1 - False - - - - domn1995 - - A simple source generator for discriminated unions in C#. - https://github.com/domn1995/dunet - Readme.md - https://github.com/domn1995/dunet - source; generator; discriminated; union; functional; tagged; - MIT - https://github.com/domn1995/dunet/releases - git - favicon.png - - - - true - - - - - - - - - - + + + enable + 1.11.4-preview1 + False + + + domn1995 + + A simple source generator for discriminated unions in C#. + https://github.com/domn1995/dunet + Readme.md + https://github.com/domn1995/dunet + source; generator; discriminated; union; functional; tagged; + MIT + https://github.com/domn1995/dunet/releases + git + favicon.png + + + true + + + + + + + + diff --git a/benchmark/Dunet.Benchmark/Dunet.Benchmark.csproj b/benchmark/Dunet.Benchmark/Dunet.Benchmark.csproj index fff40a3..a189558 100644 --- a/benchmark/Dunet.Benchmark/Dunet.Benchmark.csproj +++ b/benchmark/Dunet.Benchmark/Dunet.Benchmark.csproj @@ -1,25 +1,21 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - + + Exe + net8.0 + enable + enable + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/benchmark/Dunet.Benchmark/SourceGeneratorBenchmark.cs b/benchmark/Dunet.Benchmark/SourceGeneratorBenchmark.cs index 1f76fa6..982c28c 100644 --- a/benchmark/Dunet.Benchmark/SourceGeneratorBenchmark.cs +++ b/benchmark/Dunet.Benchmark/SourceGeneratorBenchmark.cs @@ -99,7 +99,7 @@ private static CSharpCompilation CreateCompilation(params string[] sources) => ), MetadataReference.CreateFromFile( typeof(UnionAttribute).GetTypeInfo().Assembly.Location - ) + ), ], options: new CSharpCompilationOptions(OutputKind.ConsoleApplication) ); diff --git a/integration/Dunet.Integration.csproj b/integration/Dunet.Integration.csproj index 4e7bd0a..d540d26 100644 --- a/integration/Dunet.Integration.csproj +++ b/integration/Dunet.Integration.csproj @@ -1,25 +1,22 @@ - - - net8.0 - enable - enable - false - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + + net8.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/samples/AreaCalculator/AreaCalculator.csproj b/samples/AreaCalculator/AreaCalculator.csproj index 52af506..d2e7eab 100644 --- a/samples/AreaCalculator/AreaCalculator.csproj +++ b/samples/AreaCalculator/AreaCalculator.csproj @@ -1,15 +1,12 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - + + Exe + net8.0 + enable + enable + false + + + + diff --git a/samples/ExpressionCalculator/ExpressionCalculator.csproj b/samples/ExpressionCalculator/ExpressionCalculator.csproj index 52af506..d2e7eab 100644 --- a/samples/ExpressionCalculator/ExpressionCalculator.csproj +++ b/samples/ExpressionCalculator/ExpressionCalculator.csproj @@ -1,15 +1,12 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - + + Exe + net8.0 + enable + enable + false + + + + diff --git a/samples/ExpressionCalculatorWithState/ExpressionCalculatorWithState.csproj b/samples/ExpressionCalculatorWithState/ExpressionCalculatorWithState.csproj index 52af506..d2e7eab 100644 --- a/samples/ExpressionCalculatorWithState/ExpressionCalculatorWithState.csproj +++ b/samples/ExpressionCalculatorWithState/ExpressionCalculatorWithState.csproj @@ -1,15 +1,12 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - + + Exe + net8.0 + enable + enable + false + + + + diff --git a/samples/OptionMonad/OptionMonad.csproj b/samples/OptionMonad/OptionMonad.csproj index 52af506..d2e7eab 100644 --- a/samples/OptionMonad/OptionMonad.csproj +++ b/samples/OptionMonad/OptionMonad.csproj @@ -1,15 +1,12 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - + + Exe + net8.0 + enable + enable + false + + + + diff --git a/samples/PokemonClient/PokemonClient.csproj b/samples/PokemonClient/PokemonClient.csproj index 52af506..d2e7eab 100644 --- a/samples/PokemonClient/PokemonClient.csproj +++ b/samples/PokemonClient/PokemonClient.csproj @@ -1,15 +1,12 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - + + Exe + net8.0 + enable + enable + false + + + + diff --git a/samples/Serialization/Serialization.csproj b/samples/Serialization/Serialization.csproj index 7b44c45..27b6d2a 100644 --- a/samples/Serialization/Serialization.csproj +++ b/samples/Serialization/Serialization.csproj @@ -1,16 +1,13 @@ - - - Exe - net8.0 - enable - enable - false - - - - - - - + + Exe + net8.0 + enable + enable + false + + + + + diff --git a/src/Dunet.Generator/UnionExtensionsGeneration/UnionExtensionsSourceBuilder.cs b/src/Dunet.Generator/UnionExtensionsGeneration/UnionExtensionsSourceBuilder.cs index db0230d..0c2fa9b 100644 --- a/src/Dunet.Generator/UnionExtensionsGeneration/UnionExtensionsSourceBuilder.cs +++ b/src/Dunet.Generator/UnionExtensionsGeneration/UnionExtensionsSourceBuilder.cs @@ -45,7 +45,7 @@ private static StringBuilder AppendExtensionClassDeclaration( UnionDeclaration union ) => builder.AppendLine( - $"{union.Accessibility.ToKeyword()} static class {union.Name}MatchExtensions" + $"{union.Accessibility.ToKeyword()} static class {union.Name}{union.TypeParameters.Count}MatchExtensions" ); private static StringBuilder AppendUsingStatements( diff --git a/src/Dunet.Generator/UnionGeneration/UnionGenerator.cs b/src/Dunet.Generator/UnionGeneration/UnionGenerator.cs index 57f1f72..afee0e5 100644 --- a/src/Dunet.Generator/UnionGeneration/UnionGenerator.cs +++ b/src/Dunet.Generator/UnionGeneration/UnionGenerator.cs @@ -44,9 +44,7 @@ private static void Emit(SourceProductionContext context, UnionDeclaration union var union = UnionSourceBuilder.Build(unionRecord); context.AddSource( - unionRecord.TypeParameters.Count == 0 - ? $"{unionRecord.Namespace}.{unionRecord.Name}.g.cs" - : $"{unionRecord.Namespace}.{unionRecord.Name}.{unionRecord.TypeParameters.Count}.g.cs", + $"{unionRecord.Namespace}.{unionRecord.Name}{unionRecord.TypeParameters.Count}.g.cs", SourceText.From(union, Encoding.UTF8) ); @@ -59,7 +57,7 @@ private static void Emit(SourceProductionContext context, UnionDeclaration union { var matchExtensions = UnionExtensionsSourceBuilder.GenerateExtensions(unionRecord); context.AddSource( - $"{unionRecord.Namespace}.{unionRecord.Name}MatchExtensions.g.cs", + $"{unionRecord.Namespace}.{unionRecord.Name}{unionRecord.TypeParameters.Count}MatchExtensions.g.cs", SourceText.From(matchExtensions, Encoding.UTF8) ); } diff --git a/test/Dunet.Test.csproj b/test/Dunet.Test.csproj index 29b2845..f1b3de9 100644 --- a/test/Dunet.Test.csproj +++ b/test/Dunet.Test.csproj @@ -1,29 +1,25 @@ - - - net8.0 - enable - enable - false - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - + + net8.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/test/UnionExtensionsGeneration/MultipleGenericUnionsExtensionsTests.cs b/test/UnionExtensionsGeneration/MultipleGenericUnionsExtensionsTests.cs new file mode 100644 index 0000000..73db80a --- /dev/null +++ b/test/UnionExtensionsGeneration/MultipleGenericUnionsExtensionsTests.cs @@ -0,0 +1,390 @@ +namespace Dunet.Test.UnionExtensionsGeneration; + +public sealed class MultipleGenericUnionsExtensionsTests +{ + [Fact] + public void CanGenerateMatchExtensionsForTwoGenericUnionsWithSameName() + { + // Arrange. + var resultCs = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = """ + using Results; + + var result1 = new Result.Ok(42); + var value1 = result1.Match( + ok => ok.Value * 2, + error => 0 + ); + + var result2 = new Result.Ok("success"); + var value2 = result2.Match( + ok => ok.Value.Length, + error => -1 + ); + """; + + // Act. + var result = Compiler.Compile(resultCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Fact] + public void CanGenerateMatchExtensionsForThreeGenericUnionsWithSameName() + { + // Arrange. + var responseCs = """ + using Dunet; + + namespace Responses; + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(); + } + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(TError Error); + } + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(TError Error); + public partial record Pending(TMetadata Metadata); + } + """; + + var programCs = """ + using Responses; + + var resp1 = new Response.Success(10); + var val1 = resp1.Match( + success => success.Data + 5, + failure => 0 + ); + + var resp2 = new Response.Success("hello"); + var val2 = resp2.Match( + success => success.Data.Length, + failure => -failure.Error + ); + + var resp3 = new Response.Success(true); + var val3 = resp3.Match( + success => success.Data ? 1 : 0, + failure => -1, + pending => (int)pending.Metadata + ); + """; + + // Act. + var result = Compiler.Compile(responseCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Theory] + [InlineData("Task")] + [InlineData("ValueTask")] + public void CanGenerateMatchAsyncExtensionsForMultipleGenericUnionsWithSameName(string taskType) + { + // Arrange. + var resultCs = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = $$""" + using System.Threading.Tasks; + using Results; + + async Task Test1() + { + {{taskType}}> task1 = {{taskType}}.FromResult>(new Result.Ok(42)); + var value1 = await task1.MatchAsync( + ok => Task.FromResult(ok.Value * 2), + error => Task.FromResult(0) + ); + } + + async Task Test2() + { + {{taskType}}> task2 = {{taskType}}.FromResult>(new Result.Ok("success")); + var value2 = await task2.MatchAsync( + ok => Task.FromResult(ok.Value.Length), + error => Task.FromResult(-1) + ); + } + """; + + // Act. + var result = Compiler.Compile(resultCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Fact] + public void MatchExtensionsCorrectlyRouteToDifferentVariants() + { + // Arrange. + var resultCs = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = """ + using Results; + + // Test Result - Ok variant + Result r1 = new Result.Ok(100); + var r1_result = r1.Match( + ok => ok.Value * 10, + error => -1 + ); + System.Console.WriteLine(r1_result == 1000); + + // Test Result - Error variant + Result r2 = new Result.Error(); + var r2_result = r2.Match( + ok => ok.Value * 10, + error => -1 + ); + System.Console.WriteLine(r2_result == -1); + + // Test Result - Ok variant + Result r3 = new Result.Ok("hello"); + var r3_result = r3.Match( + ok => ok.Value.Length, + error => -error.ErrorValue.Length + ); + System.Console.WriteLine(r3_result == 5); + + // Test Result - Error variant + Result r4 = new Result.Error("failure"); + var r4_result = r4.Match( + ok => ok.Value.Length, + error => -error.ErrorValue.Length + ); + System.Console.WriteLine(r4_result == -7); + """; + + // Act. + var result = Compiler.Compile(resultCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Fact] + public void MatchExtensionsWorkWithVoidReturnType() + { + // Arrange. + var resultCs = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = """ + using Results; + + Result r1 = new Result.Ok(42); + r1.Match( + ok => { System.Console.WriteLine($"Success: {ok.Value}"); }, + error => { System.Console.WriteLine("Error"); } + ); + + Result r2 = new Result.Error("oops"); + r2.Match( + ok => { System.Console.WriteLine($"Success: {ok.Value}"); }, + error => { System.Console.WriteLine($"Error: {error.ErrorValue}"); } + ); + """; + + // Act. + var result = Compiler.Compile(resultCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Fact] + public void MultipleGenericUnionsWithSameName_EachGetUniqueExtensions() + { + // Arrange. + var statusCs = """ + using Dunet; + + namespace Status; + + [Union] + public partial record Status + { + public partial record Active(T Info); + public partial record Inactive(); + } + + [Union] + public partial record Status + { + public partial record Active(T Info); + public partial record Inactive(TReason Reason); + } + """; + + var programCs = """ + using Status; + + var s1 = new Status.Active(10); + // Each should have unique Match extension based on variant count + var r1 = s1.Match(a => a.Info, i => 0); + + var s2 = new Status.Inactive("paused"); + // This should have different Match extension signature + var r2 = s2.Match(a => a.Info.Length, i => i.Reason.Length); + """; + + // Act. + var result = Compiler.Compile(statusCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } + + [Theory] + [InlineData("Task")] + [InlineData("ValueTask")] + public void MatchAsyncExtensionsWorkWithAsyncLambdas(string taskType) + { + // Arrange. + var resultCs = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = $$""" + using System.Threading.Tasks; + using Results; + + {{taskType}}> task1 = {{taskType}}.FromResult>(new Result.Ok(42)); + var value1 = await task1.MatchAsync( + async ok => { await Task.Delay(0); return ok.Value * 2; }, + async error => { await Task.Delay(0); return 0; } + ); + + {{taskType}}> task2 = {{taskType}}.FromResult>(new Result.Ok("hello")); + var value2 = await task2.MatchAsync( + async ok => { await Task.Delay(0); return ok.Value.Length; }, + async error => { await Task.Delay(0); return -1; } + ); + """; + + // Act. + var result = Compiler.Compile(resultCs, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationErrors.Should().BeEmpty(); + } +} diff --git a/test/UnionGeneration/GenerationTests.cs b/test/UnionGeneration/GenerationTests.cs index 62dbc7b..b75fd57 100644 --- a/test/UnionGeneration/GenerationTests.cs +++ b/test/UnionGeneration/GenerationTests.cs @@ -214,13 +214,13 @@ partial record Failure(Exception Error); } [Fact] - public void GenericUnionTypesMayHaveRequiredProperties() + public void GenericUnionsWithTheSameName() { // Arrange. var programCs = """ using Dunet; using System; - + Result result1 = Result.Ok(Guid.NewGuid()); Result result2 = Result.Error(new Exception("Boom!")); diff --git a/test/UnionGeneration/MultipleGenericUnionsGenerationTests.cs b/test/UnionGeneration/MultipleGenericUnionsGenerationTests.cs new file mode 100644 index 0000000..f7db348 --- /dev/null +++ b/test/UnionGeneration/MultipleGenericUnionsGenerationTests.cs @@ -0,0 +1,231 @@ +namespace Dunet.Test.UnionGeneration; + +public sealed class MultipleGenericUnionsGenerationTests +{ + [Fact] + public void TwoGenericUnionsWithSameNameDifferentTypeParameters() + { + // Arrange. + var programCs = """ + using Dunet; + + Result result1 = new Result.Ok("Success"); + Result result2 = new Result.Ok(42); + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + // Act. + var result = Compiler.Compile(programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } + + [Fact] + public void ThreeGenericUnionsWithSameNameIncreasingTypeParameters() + { + // Arrange. + var programCs = """ + using Dunet; + + Response response1 = new Response.Success("data"); + Response response2 = new Response.Success("data"); + Response response3 = new Response.Success("data"); + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(); + } + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(TError Error); + } + + [Union] + public partial record Response + { + public partial record Success(T Data); + public partial record Failure(TError Error); + public partial record Pending(TMetadata Metadata); + } + """; + + // Act. + var result = Compiler.Compile(programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } + + [Fact] + public void MultipleGenericUnionsWithSameNameInDifferentNamespaces() + { + // Arrange. + var resultCS = """ + using Dunet; + + namespace Results; + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + var programCs = """ + using Results; + + var result1 = new Result.Ok("Success"); + var result2 = new Result.Ok(42); + """; + + // Act. + var result = Compiler.Compile(resultCS, programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } + + [Fact] + public void MultipleGenericUnionsWithSameNameWithComplexVariants() + { + // Arrange. + var programCs = """ + using Dunet; + using System; + + Operation op1 = new Operation.Success(42); + Operation op2 = new Operation.Success("done"); + + [Union] + public partial record Operation + { + public partial record Success(T Result); + public partial record InProgress(double PercentComplete); + public partial record Cancelled(); + } + + [Union] + public partial record Operation + { + public partial record Success(T Result); + public partial record Failed(TError Error); + public partial record Cancelled(); + } + """; + + // Act. + var result = Compiler.Compile(programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } + + [Fact] + public void MultipleGenericUnionsWithSameNameCanInstantiateAllVariants() + { + // Arrange. + var programCs = """ + using Dunet; + + // Result + Result r1_ok = new Result.Ok(42); + Result r1_error = new Result.Error(); + + // Result + Result r2_ok = new Result.Ok("success"); + Result r2_error = new Result.Error("failed"); + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(); + } + + [Union] + public partial record Result + { + public partial record Ok(T Value); + public partial record Error(TError ErrorValue); + } + """; + + // Act. + var result = Compiler.Compile(programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } + + [Fact] + public void MultipleGenericUnionsWithSameNameWithConstraints() + { + // Arrange. + var programCs = """ + using Dunet; + + Container c1 = new Container.Filled("data"); + Container c2 = new Container.Filled(42); + + [Union] + public partial record Container where T : class + { + public partial record Filled(T Data); + public partial record Empty(); + } + + [Union] + public partial record Container where T : struct + { + public partial record Filled(T Data); + public partial record Empty(TMetadata Metadata); + } + """; + + // Act. + var result = Compiler.Compile(programCs); + + // Assert. + using var scope = new AssertionScope(); + result.CompilationErrors.Should().BeEmpty(); + result.GenerationDiagnostics.Should().BeEmpty(); + } +}