From e77296fa7273e642d7f291e57e0170160443ba9c Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sat, 2 Aug 2025 15:36:50 -0700 Subject: [PATCH 01/19] Switch to using develop branch for prereleases (#7) * update gitversion config and workflows * auto merge main to develop --- .github/workflows/publish.yml | 6 +-- .../tag-main-and-merge-to-develop.yml | 47 +++++++++++++++++++ GitVersion.yml | 11 +++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tag-main-and-merge-to-develop.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22c3671..ca276ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,10 +3,10 @@ name: Publish on: push: branches: - - main + - develop - "release/*" tags: - - "v*" + - "v*.*.*" jobs: publish: @@ -56,4 +56,4 @@ jobs: files: ./nupkgs/*.nupkg generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tag-main-and-merge-to-develop.yml b/.github/workflows/tag-main-and-merge-to-develop.yml new file mode 100644 index 0000000..9263d37 --- /dev/null +++ b/.github/workflows/tag-main-and-merge-to-develop.yml @@ -0,0 +1,47 @@ +name: Tag main and merge to develop + +on: + push: + branches: + - main + +jobs: + tag_and_merge: + runs-on: ubuntu-latest + permissions: + contents: write # Needed to push a tag + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Ensure full history is available + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + - name: Install GitVersion + uses: GitTools/actions/gitversion/setup@v0.11.0 + + - name: Run GitVersion + id: gitversion + uses: GitTools/actions/gitversion/execute@v0.11.0 + + - name: Create tag + run: | + VERSION=${{ steps.gitversion.outputs.nuGetVersionV2 }} + echo "Tagging version v$VERSION" + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag v$VERSION + git push origin v$VERSION + + - name: Merge main into develop + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git checkout develop + git pull origin develop + git merge origin/main --no-ff -m "Auto-merge main into develop [skip ci]" + git push origin develop diff --git a/GitVersion.yml b/GitVersion.yml index cda0f3e..3b98f25 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -3,9 +3,20 @@ next-version: 2.0.0 branches: main: + regex: ^main$ increment: Minor prevent-increment-of-merged-branch-version: true + is-release-branch: true + tracks-release-branches: false + is-mainline: false + tag: "" + + develop: + regex: ^develop$ + increment: Minor + is-mainline: false tag: rc + source-branches: ["main"] release: regex: ^release[/-] From 4197ef31c6dce8eb2f349c932da26eef514fda95 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sat, 2 Aug 2025 15:42:59 -0700 Subject: [PATCH 02/19] fix tag job (#9) --- .../workflows/tag-main-and-merge-to-develop.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tag-main-and-merge-to-develop.yml b/.github/workflows/tag-main-and-merge-to-develop.yml index 9263d37..e63015d 100644 --- a/.github/workflows/tag-main-and-merge-to-develop.yml +++ b/.github/workflows/tag-main-and-merge-to-develop.yml @@ -16,18 +16,20 @@ jobs: with: fetch-depth: 0 # Ensure full history is available - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.x - - - name: Install GitVersion + - name: Setup GitVersion uses: GitTools/actions/gitversion/setup@v0.11.0 + with: + versionSpec: "5.x" - name: Run GitVersion id: gitversion uses: GitTools/actions/gitversion/execute@v0.11.0 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Create tag run: | VERSION=${{ steps.gitversion.outputs.nuGetVersionV2 }} From 995162bf8ea2e3f1e0fd59ce6edc14f50ef2e5f4 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sat, 2 Aug 2025 15:55:14 -0700 Subject: [PATCH 03/19] make main mainline --- .github/workflows/tag-main-and-merge-to-develop.yml | 7 +------ GitVersion.yml | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/tag-main-and-merge-to-develop.yml b/.github/workflows/tag-main-and-merge-to-develop.yml index e63015d..2bc5191 100644 --- a/.github/workflows/tag-main-and-merge-to-develop.yml +++ b/.github/workflows/tag-main-and-merge-to-develop.yml @@ -25,11 +25,6 @@ jobs: id: gitversion uses: GitTools/actions/gitversion/execute@v0.11.0 - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - name: Create tag run: | VERSION=${{ steps.gitversion.outputs.nuGetVersionV2 }} @@ -46,4 +41,4 @@ jobs: git checkout develop git pull origin develop git merge origin/main --no-ff -m "Auto-merge main into develop [skip ci]" - git push origin develop + git push -f origin develop diff --git a/GitVersion.yml b/GitVersion.yml index 3b98f25..38a0b0d 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -8,7 +8,6 @@ branches: prevent-increment-of-merged-branch-version: true is-release-branch: true tracks-release-branches: false - is-mainline: false tag: "" develop: From d00590ef3e11a52f9581ab254e7cbaf1ba020f05 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sun, 3 Aug 2025 13:30:12 -0700 Subject: [PATCH 04/19] Prevent object boxing of unconstrained generic types (#12) * update custom box implementation * add optimizations for generic type storage * more optimizations * use readonly field for box value --- SumSharp.Generator/SymbolHandler.cs | 39 +++++++++++++++++++++++++---- SumSharp/Internal/Box.cs | 3 ++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index e834b6a..9871125 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -862,10 +862,32 @@ private void EmitCaseConstructors() {{ var ret = new {Name}({caseData.Index});"); - if (caseData.StoreAsObject && caseData.TypeInfo.IsAlwaysValueType) + if (caseData.StoreAsObject) { - Builder.AppendLine($@" + if (caseData.TypeInfo.IsAlwaysValueType) + { + Builder.AppendLine($@" ret.{caseData.FieldName} = new global::SumSharp.Internal.Box<{caseData.TypeInfo.Name}>(value);"); + } + else if (caseData.TypeInfo.IsAlwaysRefType) + { + Builder.AppendLine($@" + ret.{caseData.FieldName} = value;"); + } + else + { + // https://github.com/dotnet/runtime/issues/48605 + Builder.AppendLine($@" + // The JIT is able to optimize away this branch at runtime + if (typeof({caseData.TypeInfo.Name}).IsValueType) + {{ + ret.{caseData.FieldName} = new global::SumSharp.Internal.Box<{caseData.TypeInfo.Name}>(value); + }} + else + {{ + ret.{caseData.FieldName} = value; + }}"); + } } else if (caseData.UseUnmanagedStorage) { @@ -926,8 +948,16 @@ public void EmitAs() } else { - Builder.Append($@" - return ({caseData.TypeInfo.Name}){caseData.FieldName};"); + Builder.AppendLine($@" + // The JIT is able to optimize away this branch at runtime + if (typeof({caseData.TypeInfo.Name}).IsValueType) + {{ + return System.Runtime.CompilerServices.Unsafe.As>({caseData.FieldName}).Value; + }} + else + {{ + return System.Runtime.CompilerServices.Unsafe.As<{caseData.FieldType}, {caseData.TypeInfo.Name}>(ref {caseData.FieldName}); + }}"); } } else if (caseData.UseUnmanagedStorage) @@ -940,7 +970,6 @@ public void EmitAs() { Builder.Append($@" return {caseData.FieldName};"); - } Builder.AppendLine(@" diff --git a/SumSharp/Internal/Box.cs b/SumSharp/Internal/Box.cs index c038579..eb3b8b9 100644 --- a/SumSharp/Internal/Box.cs +++ b/SumSharp/Internal/Box.cs @@ -2,13 +2,14 @@ namespace SumSharp.Internal; -public sealed class Box(T value) : IEquatable> where T : struct +public sealed class Box(T value) : IEquatable> { public readonly T Value = value; public bool Equals(Box other) { if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; return Equals(Value, other.Value); } From 7836e06527f5749623bc17ba9547adadfd05ef28 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Mon, 4 Aug 2025 10:55:56 -0700 Subject: [PATCH 05/19] Add default match handling (#14) * add tests for match default case and exception * make tests pass * add tests for async switch * make tests pass * add xml documentation for exception * update exception message --- SumSharp.Generator/SymbolHandler.cs | 57 +++++++++++-------- SumSharp/MatchFailureException.cs | 15 +++++ Tests/Match.cs | 41 ++++++++++++++ Tests/Switch.cs | 88 +++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 SumSharp/MatchFailureException.cs diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 9871125..a187112 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -1021,19 +1021,19 @@ private void EmitSwitch() { if (caseData.TypeInfo == null) { - return $"Action handle{caseData.Name}"; + return $"Action{Nullable} {caseData.Name} = null"; } else if (caseData.TypeInfo.IsTupleType) { - return $"Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}> handle{caseData.Name}"; + return $"Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}>{Nullable} {caseData.Name} = null"; } else { - return $"Action<{caseData.TypeInfo.Name}> handle{caseData.Name}"; + return $"Action<{caseData.TypeInfo.Name}>{Nullable} {caseData.Name} = null"; } }))); - Builder.Append(")"); + Builder.Append($", Action{Nullable} _ = null)"); Builder.Append(@" { @@ -1048,8 +1048,15 @@ private void EmitSwitch() string.Join(", ", caseData.TypeInfo.TupleTypeArgs.Select((_, i) => $"As{caseData.Name}Unsafe.Item{i + 1}")) : $"As{caseData.Name}Unsafe"; + var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; + Builder.Append($@" - case {caseData.Index}: handle{caseData.Name}({arg}); break;"); + case {caseData.Index}: + if ({caseData.Name} is not null) {caseData.Name}({arg}); + else if (_ is not null) _(); + else {throwException}; + + break;"); } Builder.Append(@" @@ -1067,19 +1074,19 @@ private void EmitSwitchAsync() { if (caseData.TypeInfo == null) { - return $"Func handle{caseData.Name}"; + return $"Func{Nullable} {caseData.Name} = null"; } else if (caseData.TypeInfo.IsTupleType) { - return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task> handle{caseData.Name}"; + return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>{Nullable} {caseData.Name} = null"; } else { - return $"Func<{caseData.TypeInfo.Name}, Task> handle{caseData.Name}"; + return $"Func<{caseData.TypeInfo.Name}, Task>{Nullable} {caseData.Name} = null"; } }))); - Builder.Append(")"); + Builder.Append($", Func{Nullable} _ = null)"); Builder.Append(@" { @@ -1094,8 +1101,10 @@ private void EmitSwitchAsync() string.Join(", ", caseData.TypeInfo.TupleTypeArgs.Select((_, i) => $"As{caseData.Name}Unsafe.Item{i + 1}")) : $"As{caseData.Name}Unsafe"; + var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; + Builder.Append($@" - {caseData.Index} => handle{caseData.Name}({arg}),"); + {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); } Builder.Append(@" @@ -1113,19 +1122,19 @@ private void EmitMatch() { if (caseData.TypeInfo == null) { - return $"Func handle{caseData.Name}"; + return $"Func{Nullable} {caseData.Name} = null"; } else if (caseData.TypeInfo.IsTupleType) { - return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_> handle{caseData.Name}"; + return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>{Nullable} {caseData.Name} = null"; } else { - return $"Func<{caseData.TypeInfo.Name}, TRet_> handle{caseData.Name}"; + return $"Func<{caseData.TypeInfo.Name}, TRet_>{Nullable} {caseData.Name} = null"; } }))); - Builder.Append(")"); + Builder.Append($", Func{Nullable} _ = null)"); Builder.Append(@" { @@ -1140,8 +1149,10 @@ private void EmitMatch() string.Join(", ", caseData.TypeInfo.TupleTypeArgs.Select((_, i) => $"As{caseData.Name}Unsafe.Item{i + 1}")) : $"As{caseData.Name}Unsafe"; + var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; + Builder.Append($@" - {caseData.Index} => handle{caseData.Name}({arg}),"); + {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); } Builder.Append(@" @@ -1164,8 +1175,8 @@ private void EmitIf() var funcArgType = caseData.TypeInfo.IsTupleType ? - $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet__>" : - $"Func<{caseData.TypeInfo.Name}, TRet__>"; + $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>" : + $"Func<{caseData.TypeInfo.Name}, TRet_>"; var handlerName = $"handle{caseData.Name}"; @@ -1210,14 +1221,14 @@ private void EmitIf() ///function with the {caseData.TypeInfo.Name} value, otherwise returns . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Value to be returned if the {Name} does not hold a {caseData.Name} - public TRet__ If{caseData.Name}Else({funcArgType} {handlerName}, TRet__ elseValue) => Index == {caseData.Index} ? {invokeHandler} : elseValue;"); + public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, TRet_ elseValue) => Index == {caseData.Index} ? {invokeHandler} : elseValue;"); Builder.AppendLine($@" ///If the {Name} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Produces the value to be returned if the {Name} does not hold a {caseData.Name} - public TRet__ If{caseData.Name}Else({funcArgType} {handlerName}, Func elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); + public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, Func elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } } @@ -1238,8 +1249,8 @@ private void EmitIfAsync() var funcArgType = caseData.TypeInfo.IsTupleType ? - $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : - $"Func<{caseData.TypeInfo.Name}, Task>"; + $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : + $"Func<{caseData.TypeInfo.Name}, Task>"; var handlerName = $"{caseData.Name}Handler"; @@ -1267,14 +1278,14 @@ private void EmitIfAsync() ///function with the {caseData.TypeInfo.Name} value, otherwise returns wrapped in a ValueTask. ///Function to be invoked with the {caseData.Name} value, if it exists. ///Value to be returned if the {Name} does not hold a {caseData.Name} - public ValueTask If{caseData.Name}Else({funcArgType} {handlerName}, TRet__ elseValue) => Index == {caseData.Index} ? new ValueTask({invokeHandler}) : ValueTask.FromResult(elseValue);"); + public ValueTask If{caseData.Name}Else({funcArgType} {handlerName}, TRet_ elseValue) => Index == {caseData.Index} ? new ValueTask({invokeHandler}) : ValueTask.FromResult(elseValue);"); Builder.AppendLine($@" ///If the {Name} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Produces the value to be returned if the {Name} does not hold a {caseData.Name} - public Task If{caseData.Name}Else({funcArgType} {handlerName}, Func> elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); + public Task If{caseData.Name}Else({funcArgType} {handlerName}, Func> elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } } diff --git a/SumSharp/MatchFailureException.cs b/SumSharp/MatchFailureException.cs new file mode 100644 index 0000000..aedde82 --- /dev/null +++ b/SumSharp/MatchFailureException.cs @@ -0,0 +1,15 @@ +using System; + +namespace SumSharp; + +/// +/// Thrown when a Match or Switch invocation on a union lacks a handler for the active case +/// +/// The name of the active case held by the union +public sealed class MatchFailureException(string caseName) : Exception($"Failed to handle case {caseName}") +{ + /// + /// The name of the active case held by the union + /// + public string CaseName => caseName; +} diff --git a/Tests/Match.cs b/Tests/Match.cs index c46da09..433bc0c 100644 --- a/Tests/Match.cs +++ b/Tests/Match.cs @@ -20,6 +20,13 @@ partial class ContainsTuple } + [UnionCase("Ok", "T")] + [UnionCase("Error", "E")] + partial class Result + { + + } + [Fact] public void Case0() { @@ -74,4 +81,38 @@ public void TupleMatch() Assert.True(passed); } + + [Fact] + public void NamedMatchNoDefault() + { + var passed = + Result.Ok("abc").Match( + Ok: str => str == "abc", + Error: _ => false); + + Assert.True(passed); + } + + [Fact] + public void NamedMatchWithDefault() + { + var passed = + Result.Error(new InvalidOperationException()).Match( + Ok: str => false, + _: () => true); + + Assert.True(passed); + } + + [Fact] + public void UnhandledCaseException() + { + var err = Assert.Throws(() => + { + Result.Ok("abc").Match( + Error: _ => true); + }); + + Assert.Equal("Ok", err.CaseName); + } } \ No newline at end of file diff --git a/Tests/Switch.cs b/Tests/Switch.cs index 7aea5c2..b32ac58 100644 --- a/Tests/Switch.cs +++ b/Tests/Switch.cs @@ -20,6 +20,13 @@ partial class ContainsTuple } + [UnionCase("Ok", "T")] + [UnionCase("Error", "E")] + partial class Result + { + + } + [Fact] public void Case0() { @@ -103,4 +110,85 @@ await ContainsTuple.Case1("a", "b").Switch( Assert.True(passed); } + + [Fact] + public void NamedSwitchNoDefault() + { + bool passed = false; + + Result.Ok("abc").Switch( + Ok: str => passed = str == "abc", + Error: _ => { }); + + Assert.True(passed); + } + + [Fact] + public async Task NamedSwitchNoDefaultAsync() + { + bool passed = false; + + await Result.Ok("abc").Switch( + Ok: str => + { + passed = str == "abc"; + + return Task.CompletedTask; + }, + Error: _ => Task.CompletedTask); + + Assert.True(passed); + } + + [Fact] + public void NamedSwitchWithDefault() + { + bool passed = false; + + Result.Error(new InvalidOperationException()).Switch( + Ok: str => { }, + _: () => passed = true); + + Assert.True(passed); + } + + [Fact] + public async Task NamedSwitchWithDefaultAsync() + { + bool passed = false; + + await Result.Error(new InvalidOperationException()).Switch( + Ok: str => Task.CompletedTask, + _: () => + { + passed = true; + return Task.CompletedTask; + }); + + Assert.True(passed); + } + + [Fact] + public void UnhandledCaseException() + { + var err = Assert.Throws(() => + { + Result.Ok("abc").Switch( + Error: _ => { }); + }); + + Assert.Equal("Ok", err.CaseName); + } + + [Fact] + public async Task UnhandledCaseExceptionAsync() + { + var err = await Assert.ThrowsAsync(async () => + { + await Result.Ok("abc").Switch( + Error: _ => Task.CompletedTask); + }); + + Assert.Equal("Ok", err.CaseName); + } } \ No newline at end of file From c68f7dc2e7190e43d66adf0339ed4ccaa0ece9fe Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Mon, 4 Aug 2025 16:00:46 -0700 Subject: [PATCH 06/19] add analyzer with non-exhaustive match warning (#15) --- README.md | 99 ++++++++++++---- SumSharp.Analyzer/MatchAnalyzer.cs | 99 ++++++++++++++++ SumSharp.Analyzer/Resources.Designer.cs | 105 ++++++++++++++++ SumSharp.Analyzer/Resources.resx | 132 +++++++++++++++++++++ SumSharp.Analyzer/SumSharp.Analyzer.csproj | 20 ++++ SumSharp.Generator/SymbolHandler.cs | 86 ++++++++------ SumSharp.sln | 6 + SumSharp/SumSharp.csproj | 4 +- Tests.AOT/Tests.AOT.csproj | 1 + Tests/Match.cs | 17 ++- Tests/Switch.cs | 12 +- Tests/Tests.csproj | 3 + 12 files changed, 514 insertions(+), 70 deletions(-) create mode 100644 SumSharp.Analyzer/MatchAnalyzer.cs create mode 100644 SumSharp.Analyzer/Resources.Designer.cs create mode 100644 SumSharp.Analyzer/Resources.resx create mode 100644 SumSharp.Analyzer/SumSharp.Analyzer.csproj diff --git a/README.md b/README.md index ab98e70..dbc68a2 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ A highly configurable C# discriminated union library --- -1. [Installation](#installation) -2. [Features](#features) +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) 4. [Motivation](#motivation) - [What about `OneOf`?](#what-about-oneof) - [Typical DU implementation approaches](#typical-du-implementation-approaches) @@ -33,22 +34,36 @@ A highly configurable C# discriminated union library --- -## Installation +## Why use SumSharp? -```bash -dotnet add package SumSharp -``` +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 that would be expected from true, language level discriminated union types. -## Features +`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, Haskell, and Scala. Although it's impossible to exactly replicate the functionality that those other languages offer, `SumSharp` attempts to get as close as possible. + +### Features - Unlimited number of cases - Support for class, struct, record, and record struct union types - Support for generic type cases +- Expressive match syntax with exhaustiveness checking +- Implicit conversions from types (as long as there's only one case of that type in the union) +- Convenient handling of tuple types - **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 +- 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 +- Configurable equality definitions (choose between reference or value equality for class unions) + +--- + +## Installation + +```bash +dotnet add package SumSharp +``` + +Or install via the Nuget package manager in Visual Studio. --- @@ -77,7 +92,7 @@ That's it! `SumSharp` will generate members for the `StringOrDouble` class that - `Switch`, `Match`, `IfString`, and `IfDouble` functions for control flow - An `Index` int property that reflects the current case - Implicit conversions from string/double to `StringOrDouble` -- Implementation of the `IEquatable` interface, `Object.Equals` override and `==` and `!=` operators to allow for value equality comparisons +- Implementation of the `IEquatable` interface, `Object.Equals` override, and `==` and `!=` operators to allow for value equality comparisons - Various overloads of `As[CaseName]` and `If[CaseName]` to allow for more expressive control flow ```csharp @@ -85,15 +100,15 @@ var x = StringOrDouble.Double(3.14); // Prints "Value is a double: 3.14" x.Switch( - value => Console.WriteLine($"Value is a string: {value}"), - value => Console.WriteLine($"Value is a double: {value}")); + String: s => Console.WriteLine($"Value is a string: {s}"), + Double: d => Console.WriteLine($"Value is a double: {d}")); StringOrDouble y = "abcdefg"; // result is "Value is a string: abcdefg" var result = y.Match( - value => $"Value is a string: {value}", - value => $"Value is a double: {value}"); + String: s => $"Value is a string: {s}", + Double: d => $"Value is a double: {d}"); // Prints "abcdefg" Console.WriteLine(y.AsString); @@ -123,13 +138,53 @@ Case types can be generic. To define a generic case you must supply the **name** ```csharp [UnionCase("Some", "T")] -[UnionCase("Empty")] +[UnionCase("None")] partial class Optional { } ``` +### The `Match` function + +Performing a "match" on a discriminated union for control flow is a common need. `SumSharp` unions have a `Match` member function that provides this functionality (`Switch` and its async overload provide equivalent functionality for void returning handlers). 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. + +```csharp +// Here myOptionalValue is an Optional +// The "None" handler can come before the "Some" handler as long as they're both named +var result = myOptionalValue.Match( + None: () => "", + Some: x => x); +``` + +Corresponding F\# code would look like: + +```fsharp +let result = match myOptionalValue with + | None -> "" + | Some x -> x +``` + +Handling each case is not required, but a warning will be emitted by the `SumSharp` analyzer if the handling is non-exhaustive. It can be a good idea to treat this warning as an error. A match or switch statement that fails to handle a case at runtime will throw a `SumSharp.MatchFailureException`. + +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( + Some: x => x, + _: () => ""); +``` + +Again, the corresponding F\# code would look like: + +```fsharp +let result = match myOptionalValue with + | Some x -> x + | _ -> "" +``` + +The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match`/`Switch` that is already exhaustive. + --- ## Motivation @@ -138,12 +193,12 @@ C\# unfortunately does not offer discriminated unions as a language feature. Alt ### What about `OneOf`? -[OneOf](https://github.com/mcintyre321/OneOf) is a popular existing discriminated union library for C\# that I have personally used and found very helpful. There are, however, several pain points in using `OneOf` that I have encountered, such as: +`OneOf` is the most popular discriminated union library for C\#. I have personally used and it found it very helpful. There are, however, several pain points in using `OneOf` that I have encountered, such as: -- Limited number of cases (The base library limits you to 8. There is an extended version that allows up to 32) +- Limited number of cases (The base library limits you to 9. There is an extended version that allows up to 32) - No support for case "names" - The underlying implementation uses a dedicated field for each individual case, resulting in a larger memory footprint than is neccessary -- Limited support for JSON serialization (There is a [separate package](https://github.com/Liversage/OneOf.Serialization.SystemTextJson) that provides System.Text.Json serialization support) +- Limited support for JSON serialization (There is a [separate package](https://github.com/Liversage/OneOf.Serialization.SystemTextJson) that provides `System.Text.Json serialization` support) - All `OneOf` instances are structs and all user defined types inheriting from `OneOfBase` must be classes. No ability to pick and choose the type kind you want to use Overall `OneOf` is an excellent library that has served me and many other developers well, but I felt that with the advent of C\# source generators it would be possible to produce a more powerful discriminated union library. @@ -364,16 +419,16 @@ var x = UnionWithTuple.Case0(5, "abc"); // "Switch", "Match", and "If" function handlers work with the individual items rather than the tuple type itself x.Switch( - (i, s) => + Case0: (i, s) => { Console.WriteLine(i); Console.WriteLine(s); }, - (f) => {}); + Case1: f => {}); var s = x.Match( - (i, s) => s + i.ToString(), - (f) => f.ToString()); + Case0: (i, s) => s + i.ToString(), + Case1: f => f.ToString()); Console.WriteLine(s); diff --git a/SumSharp.Analyzer/MatchAnalyzer.cs b/SumSharp.Analyzer/MatchAnalyzer.cs new file mode 100644 index 0000000..d7d113a --- /dev/null +++ b/SumSharp.Analyzer/MatchAnalyzer.cs @@ -0,0 +1,99 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; + +namespace SumSharp.Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MatchAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor NonExhaustiveMatchRule = new DiagnosticDescriptor( + "SumSharp0001", + title: "Non-exhaustive match", + messageFormat: "Failure to handle cases: {0}. Handle all cases or provide a default case (_) handler", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor RedundantDefaultCaseRule = new DiagnosticDescriptor( + "SumSharp0002", + title: "Redundant default case", + messageFormat: "All cases are handled. Default case handler will never be used", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(NonExhaustiveMatchRule, RedundantDefaultCaseRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + + var invocation = (InvocationExpressionSyntax)context.Node; + + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation); + + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + if (methodSymbol.Name != "Match" && methodSymbol.Name != "Switch") + { + return; + } + + var caseNames = GetCaseNames(methodSymbol.ContainingType); + + if (caseNames.Length == 0) + { + return; + } + + var passedArgs = + invocation.ArgumentList.Arguments + .Select((arg, i) => arg.NameColon?.Name.Identifier.Text ?? caseNames[i]) + .ToImmutableHashSet(); + + bool hasDefaultHandler = passedArgs.Contains("_"); + + var missingCases = caseNames.Where(name => !passedArgs.Contains(name)).ToArray(); + + if (missingCases.Length > 0 && !hasDefaultHandler) + { + var diagnostic = Diagnostic.Create( + NonExhaustiveMatchRule, + invocation.GetLocation(), + $"[{string.Join(", ", missingCases)}]"); + + context.ReportDiagnostic(diagnostic); + } + else if (missingCases.Length == 0 && hasDefaultHandler) + { + var diagnostic = Diagnostic.Create( + RedundantDefaultCaseRule, + invocation.GetLocation()); + + context.ReportDiagnostic(diagnostic); + } + } + + private string[] GetCaseNames(INamedTypeSymbol type) + { + return + type.GetAttributes() + .Where(attr => attr.AttributeClass.Name == "UnionCaseAttribute") + .Select(attr => (string)attr.ConstructorArguments[0].Value!) + .ToArray(); + } +} diff --git a/SumSharp.Analyzer/Resources.Designer.cs b/SumSharp.Analyzer/Resources.Designer.cs new file mode 100644 index 0000000..aaf06c9 --- /dev/null +++ b/SumSharp.Analyzer/Resources.Designer.cs @@ -0,0 +1,105 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +namespace SumSharp.Analyzer +{ + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SumSharp.Analyzer.Resources", typeof(Resources).GetTypeInfo().Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Type names should be all uppercase.. + /// + internal static string AnalyzerDescription + { + get + { + return ResourceManager.GetString("AnalyzerDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type name '{0}' contains lowercase letters. + /// + internal static string AnalyzerMessageFormat + { + get + { + return ResourceManager.GetString("AnalyzerMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type name contains lowercase letters. + /// + internal static string AnalyzerTitle + { + get + { + return ResourceManager.GetString("AnalyzerTitle", resourceCulture); + } + } + } +} diff --git a/SumSharp.Analyzer/Resources.resx b/SumSharp.Analyzer/Resources.resx new file mode 100644 index 0000000..410edcc --- /dev/null +++ b/SumSharp.Analyzer/Resources.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Type names should be all uppercase. + An optional longer localizable description of the diagnostic. + + + Type name '{0}' contains lowercase letters + The format-able message the diagnostic displays. + + + Type name contains lowercase letters + The title of the diagnostic. + + \ No newline at end of file diff --git a/SumSharp.Analyzer/SumSharp.Analyzer.csproj b/SumSharp.Analyzer/SumSharp.Analyzer.csproj new file mode 100644 index 0000000..f2cc1c8 --- /dev/null +++ b/SumSharp.Analyzer/SumSharp.Analyzer.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + false + latest + enable + + + + + + + + + + + + + diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index a187112..6b8727a 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -236,6 +236,8 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public string Name { get; } + public string XMLEscapedName { get; } + public CaseData[] Cases { get; } public CaseData[] UniqueCases { get; } @@ -315,6 +317,8 @@ public SymbolHandler( Name = GetFullName(symbol); + XMLEscapedName = Name.Replace("<", "<").Replace(">", ">"); + UnmanagedStorageNamespace = $"SumSharp.Internal.Generated.{FileFriendlyName}"; ITypeSymbol[] allGenericTypeArguments = @@ -775,7 +779,7 @@ public void EmitUnmanagedStorageSize() public void EmitEquals() { Builder.Append($@" - ///Compares two {Name} instances for equality. The two instances are equal iff they have the same Index and their underlying values compare equal using Object.Equals + ///Compares two {XMLEscapedName} instances for equality. The two instances are equal iff they have the same Index and their underlying values compare equal using Object.Equals public bool Equals({Name}{NullableIfRef} other) {{ {(IsStruct ? "" : "if (ReferenceEquals(null, other)) return false;")} @@ -802,8 +806,8 @@ public bool Equals({Name}{NullableIfRef} other) }}; }} - ///Compares a {Name} instance and another object for equality. The {Name} instance is equal to the other object iff - /// the other object is a {Name} and they have the same Index and their underlying values compare equal using Object.Equals + ///Compares a {XMLEscapedName} instance and another object for equality. The {XMLEscapedName} instance is equal to the other object iff + /// the other object is a {XMLEscapedName} and they have the same Index and their underlying values compare equal using Object.Equals public override bool Equals(object{Nullable} obj) {{ if (ReferenceEquals(null, obj)) return false; @@ -836,10 +840,10 @@ public override int GetHashCode() }}; }} - ///Compares two {Name} instances for equality using IEquatable<{Name}>.Equals + ///Compares two {XMLEscapedName} instances for equality using IEquatable<{XMLEscapedName}>.Equals public static bool operator==({Name} left, {Name} right) => left.Equals(right); - ///Compares two {Name} instances for inequality using IEquatable<{Name}>.Equals + ///Compares two {XMLEscapedName} instances for inequality using IEquatable<{XMLEscapedName}>.Equals public static bool operator!=({Name} left, {Name} right) => !left.Equals(right);"); } private void EmitCaseConstructors() @@ -850,14 +854,14 @@ private void EmitCaseConstructors() { Builder.AppendLine($@" private static readonly {Name} _{caseData.Name} = new({caseData.Index}); - ///The singleton {Name} that holds a {caseData.Name} + ///The singleton {XMLEscapedName} that holds a {caseData.Name} public static {Name} {caseData.Name} => _{caseData.Name};"); continue; } Builder.AppendLine($@" - ///A static function that creates a {Name} that holds a {caseData.Name} + ///A static function that creates a {XMLEscapedName} that holds a {caseData.Name} public static {Name} {caseData.Name}({caseData.TypeInfo.Name} value) {{ var ret = new {Name}({caseData.Index});"); @@ -911,7 +915,7 @@ private void EmitCaseConstructors() var tupleValue = string.Join(", ", caseData.TypeInfo.TupleTypeArgs.Select((_, i) => $"item{i + 1}")); Builder.AppendLine($@" - ///A static function that creates a {Name} that holds a {caseData.Name} + ///A static function that creates a {XMLEscapedName} that holds a {caseData.Name} public static {Name} {caseData.Name}({tupleArgs}) => {caseData.Name}(({tupleValue}));") ; } @@ -977,8 +981,8 @@ public void EmitAs() }"); Builder.AppendLine($@" - ///The {caseData.Name} value, if present. Throws InvalidOperationException if the {Name} does not hold a {caseData.Name} - ///Thrown if the {Name} does not hold a {caseData.Name} + ///The {caseData.Name} value, if present. Throws InvalidOperationException if the {XMLEscapedName} does not hold a {caseData.Name} + ///Thrown if the {XMLEscapedName} does not hold a {caseData.Name} public {caseData.TypeInfo.Name} As{caseData.Name} => Index == {caseData.Index} ? As{caseData.Name}Unsafe : throw new InvalidOperationException($""Attempted to access case index {caseData.Index} but index is {{Index}}"");"); Builder.AppendLine($@" @@ -987,17 +991,17 @@ public void EmitAs() Builder.AppendLine($@" ///Returns the {caseData.Name} value, if present. Otherwise returns - ///The default value to return if the {Name} does not hold a {caseData.Name} + ///The default value to return if the {XMLEscapedName} does not hold a {caseData.Name} public {caseData.TypeInfo.Name} As{caseData.Name}Or({caseData.TypeInfo.Name} defaultValue) => Index == {caseData.Index} ? As{caseData.Name}Unsafe : defaultValue;"); Builder.AppendLine($@" ///Returns the {caseData.Name} value, if present. Otherwise returns the result of invoking - ///Provides the default value to return if the {Name} does not hold a {caseData.Name} + ///Provides the default value to return if the {XMLEscapedName} does not hold a {caseData.Name} public {caseData.TypeInfo.Name} As{caseData.Name}Or(Func<{caseData.TypeInfo.Name}> defaultValueFactory) => Index == {caseData.Index} ? As{caseData.Name}Unsafe : defaultValueFactory();"); Builder.AppendLine($@" ///Returns a ValueTask wrapping the {caseData.Name} value, if present. Otherwise returns the result of invoking - ///Provides the default value to return if the {Name} does not hold a {caseData.Name} + ///Provides the default value to return if the {XMLEscapedName} does not hold a {caseData.Name} public ValueTask<{caseData.TypeInfo.Name}> As{caseData.Name}Or(Func> defaultValueFactory) => Index == {caseData.Index} ? ValueTask.FromResult(As{caseData.Name}Unsafe) : new ValueTask<{caseData.TypeInfo.Name}>(defaultValueFactory());"); } } @@ -1006,7 +1010,7 @@ public void EmitIs() foreach (var caseData in Cases) { Builder.AppendLine($@" - ///True if the {Name} holds a {caseData.Name}, false otherwise + ///True if the {XMLEscapedName} holds a {caseData.Name}, false otherwise public bool Is{caseData.Name} => Index == {caseData.Index};"); } } @@ -1014,7 +1018,9 @@ public void EmitIs() private void EmitSwitch() { Builder.Append($@" - ///Invokes the corresponding function with the underlying value held by the {Name} + ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName}. Throws + /// if no handler or default handler is provided for the active case + ///Thrown when no handler or default handler is provided for the active case public void Switch("); Builder.Append(string.Join(", ", Cases.Select(caseData => @@ -1067,7 +1073,9 @@ private void EmitSwitch() private void EmitSwitchAsync() { Builder.Append($@" - ///Invokes the corresponding function with the underlying value held by the {Name} + ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName}. Throws + /// if no handler or default handler is provided for the active case + ///Thrown when no handler or default handler is provided for the active case public Task Switch("); Builder.Append(string.Join(", ", Cases.Select(caseData => @@ -1104,7 +1112,7 @@ private void EmitSwitchAsync() var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; Builder.Append($@" - {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); + {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); } Builder.Append(@" @@ -1115,7 +1123,9 @@ private void EmitSwitchAsync() private void EmitMatch() { Builder.Append($@" - ///Invokes the corresponding function with the underlying value held by the {Name} and returns the result + ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName} and returns the result. Throws + /// if no handler or default handler is provided for the active case + ///Thrown when no handler or default handler is provided for the active case public TRet_ Match("); Builder.Append(string.Join(", ", Cases.Select(caseData => @@ -1152,7 +1162,7 @@ private void EmitMatch() var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; Builder.Append($@" - {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); + {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); } Builder.Append(@" @@ -1187,7 +1197,7 @@ private void EmitIf() var invokeHandler = $"{handlerName}({(caseData.TypeInfo.IsTupleType ? deconstructedTupleArgs : arg)})"; Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, invokes the function with the + ///If the {XMLEscapedName} holds a {caseData.Name}, invokes the function with the ///{caseData.TypeInfo.Name} value, otherwise does nothing. ///Function to be invoked with the {caseData.TypeInfo.Name} value, if it exists. public void If{caseData.Name}({actionArgType} {handlerName}) @@ -1200,10 +1210,10 @@ private void EmitIf() Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, invokes the function with the + ///If the {XMLEscapedName} holds a {caseData.Name}, invokes the function with the ///{caseData.TypeInfo.Name} value, otherwise invokes . ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Function to be invoked if the {Name} does not hold a {caseData.Name} + ///Function to be invoked if the {XMLEscapedName} does not hold a {caseData.Name} public void If{caseData.Name}Else({actionArgType} {handlerName}, Action orElse) {{ if (Index == {caseData.Index}) @@ -1217,17 +1227,17 @@ private void EmitIf() }}"); Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, returns the result of invoking the + ///If the {XMLEscapedName} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns . ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Value to be returned if the {Name} does not hold a {caseData.Name} + ///Value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, TRet_ elseValue) => Index == {caseData.Index} ? {invokeHandler} : elseValue;"); Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, returns the result of invoking the + ///If the {XMLEscapedName} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Produces the value to be returned if the {Name} does not hold a {caseData.Name} + ///Produces the value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, Func elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } @@ -1261,30 +1271,30 @@ private void EmitIfAsync() var invokeHandler = $"{handlerName}({(caseData.TypeInfo.IsTupleType ? deconstructedTupleArgs : arg)})"; Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, invokes the function with the + ///If the {XMLEscapedName} holds a {caseData.Name}, invokes the function with the ///{caseData.TypeInfo.Name} value, otherwise does nothing. ///Function to be invoked with the {caseData.TypeInfo.Name} value, if it exists. public ValueTask If{caseData.Name}({actionArgType} {handlerName}) => Index == {caseData.Index} ? new ValueTask({invokeHandler}) : ValueTask.CompletedTask;"); Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, invokes the function with the + ///If the {XMLEscapedName} holds a {caseData.Name}, invokes the function with the ///{caseData.TypeInfo.Name} value, otherwise invokes orElse. ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Function to be invoked if the {Name} does not hold a {caseData.Name} + ///Function to be invoked if the {XMLEscapedName} does not hold a {caseData.Name} public Task If{caseData.Name}Else({actionArgType} {handlerName}, Func elseF) => Index == {caseData.Index} ? {invokeHandler} : elseF();"); Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, returns the result of invoking the + ///If the {XMLEscapedName} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns wrapped in a ValueTask. ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Value to be returned if the {Name} does not hold a {caseData.Name} + ///Value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} public ValueTask If{caseData.Name}Else({funcArgType} {handlerName}, TRet_ elseValue) => Index == {caseData.Index} ? new ValueTask({invokeHandler}) : ValueTask.FromResult(elseValue);"); Builder.AppendLine($@" - ///If the {Name} holds a {caseData.Name}, returns the result of invoking the + ///If the {XMLEscapedName} holds a {caseData.Name}, returns the result of invoking the ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. - ///Produces the value to be returned if the {Name} does not hold a {caseData.Name} + ///Produces the value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} public Task If{caseData.Name}Else({funcArgType} {handlerName}, Func> elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } } @@ -1299,7 +1309,7 @@ private void EmitImplicitConversions() } Builder.AppendLine($@" - ///Converts a {caseData.TypeInfo!.Name} to a {Name} that holds a {caseData.Name} + ///Converts a {caseData.TypeInfo!.Name} to a {XMLEscapedName} that holds a {caseData.Name} public static implicit operator {Name}({caseData.TypeInfo!.Name} value) => {caseData.Name}(value);"); } } @@ -1313,7 +1323,7 @@ private void EmitOneOfConversions() var conversionFuncs = Cases.Select(caseData => caseData.TypeInfo == null ? $"static _ => {caseData.Name}" : caseData.Name); Builder.AppendLine($@" - ///Converts a {oneOfNameShort} to a {Name} + ///Converts a {oneOfNameShort} to a {XMLEscapedName} public static implicit operator {Name}({oneOfName} value) {{ return value.Match({string.Join(", ", conversionFuncs)}); @@ -1322,7 +1332,7 @@ private void EmitOneOfConversions() conversionFuncs = Cases.Select(caseData => caseData.TypeInfo == null ? $"static () => {oneOfName}.FromT{caseData.Index}(new {OneOfEmptyCase}())" : $"static _ => {oneOfName}.FromT{caseData.Index}(_)"); Builder.Append($@" - ///Converts a {Name} to a {oneOfNameShort} + ///Converts a {XMLEscapedName} to a {oneOfNameShort} public static implicit operator {oneOfName}({Name} value) {{ return value.Match({string.Join(", ", conversionFuncs)}); @@ -1367,7 +1377,7 @@ public override string ToString() private void EmitStandardJsonConverter() { Builder.Append($@" - ///System.Text.Json converter capable of serializing and deserializing a {Name} + ///System.Text.Json converter capable of serializing and deserializing a {XMLEscapedName} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverter<{Name}> {{ public override {Name}{NullableIfRef} Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) @@ -1471,7 +1481,7 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, {Name}{Nullab private void EmitNewtonsoftJsonConverter() { Builder.Append($@" - ///Newtonsoft converter capable of serializing and deserializing a {Name} + ///Newtonsoft converter capable of serializing and deserializing a {XMLEscapedName} 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) diff --git a/SumSharp.sln b/SumSharp.sln index cca49e6..aed0033 100644 --- a/SumSharp.sln +++ b/SumSharp.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp.Generator", "SumSh EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AOT", "Tests.AOT\Tests.AOT.csproj", "{AB02E647-82CD-4DB7-9207-3661E7FD933B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp.Analyzer", "SumSharp.Analyzer\SumSharp.Analyzer.csproj", "{D50E46B6-6A70-4A3C-A89F-348A1566825F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {AB02E647-82CD-4DB7-9207-3661E7FD933B}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB02E647-82CD-4DB7-9207-3661E7FD933B}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB02E647-82CD-4DB7-9207-3661E7FD933B}.Release|Any CPU.Build.0 = Release|Any CPU + {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SumSharp/SumSharp.csproj b/SumSharp/SumSharp.csproj index 0cb2f9f..5e4b1f3 100644 --- a/SumSharp/SumSharp.csproj +++ b/SumSharp/SumSharp.csproj @@ -44,12 +44,14 @@ + false Content PreserveNewest + True \ @@ -61,7 +63,7 @@ - + diff --git a/Tests.AOT/Tests.AOT.csproj b/Tests.AOT/Tests.AOT.csproj index 4de9cf4..9cd1b59 100644 --- a/Tests.AOT/Tests.AOT.csproj +++ b/Tests.AOT/Tests.AOT.csproj @@ -23,6 +23,7 @@ + diff --git a/Tests/Match.cs b/Tests/Match.cs index 433bc0c..7824e8f 100644 --- a/Tests/Match.cs +++ b/Tests/Match.cs @@ -87,8 +87,8 @@ public void NamedMatchNoDefault() { var passed = Result.Ok("abc").Match( - Ok: str => str == "abc", - Error: _ => false); + Error: _ => false, + Ok: str => str == "abc"); Assert.True(passed); } @@ -105,7 +105,7 @@ public void NamedMatchWithDefault() } [Fact] - public void UnhandledCaseException() + public void NonExhaustiveMatch() { var err = Assert.Throws(() => { @@ -115,4 +115,15 @@ public void UnhandledCaseException() Assert.Equal("Ok", err.CaseName); } + + [Fact] + public void RedundantDefaultCase() + { + var passed = Result.Ok("a").Match( + Ok: str => str == "a", + Error: _ => false, + _: () => false); + + Assert.True(passed); + } } \ No newline at end of file diff --git a/Tests/Switch.cs b/Tests/Switch.cs index b32ac58..f98e617 100644 --- a/Tests/Switch.cs +++ b/Tests/Switch.cs @@ -157,7 +157,7 @@ public async Task NamedSwitchWithDefaultAsync() { bool passed = false; - await Result.Error(new InvalidOperationException()).Switch( + await Result.Error(new Exception()).Switch( Ok: str => Task.CompletedTask, _: () => { @@ -169,7 +169,7 @@ await Result.Error(new InvalidOperationException()).Switch( } [Fact] - public void UnhandledCaseException() + public void NonExhaustiveSwitch() { var err = Assert.Throws(() => { @@ -181,14 +181,14 @@ public void UnhandledCaseException() } [Fact] - public async Task UnhandledCaseExceptionAsync() + public async Task NonExhaustiveSwitchAsync() { var err = await Assert.ThrowsAsync(async () => { - await Result.Ok("abc").Switch( - Error: _ => Task.CompletedTask); + await Result.Error(new Exception()).Switch( + Ok: _ => Task.CompletedTask); }); - Assert.Equal("Ok", err.CaseName); + Assert.Equal("Error", err.CaseName); } } \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index c4200ae..5aba39a 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,10 +13,12 @@ True + SumSharp0001;SumSharp0002 True + SumSharp0001;SumSharp0002 @@ -31,6 +33,7 @@ + From 5c68ef9f07e0301259fc5ed236938e2efe015166 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Mon, 4 Aug 2025 16:14:01 -0700 Subject: [PATCH 07/19] fix project reference (#16) --- SumSharp/SumSharp.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SumSharp/SumSharp.csproj b/SumSharp/SumSharp.csproj index 5e4b1f3..f7916e5 100644 --- a/SumSharp/SumSharp.csproj +++ b/SumSharp/SumSharp.csproj @@ -44,7 +44,11 @@ - + + false + Content + PreserveNewest + false Content From 344fb145eb1f7baa2afc294ad2ae91897d53281b Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Tue, 5 Aug 2025 10:23:16 -0700 Subject: [PATCH 08/19] Add unnamed parameter warning (#17) * add unnamed parameter warning * update readme --- README.md | 16 +++++----- SumSharp.Analyzer/MatchAnalyzer.cs | 50 +++++++++++++++++++++++------- Tests/Tests.csproj | 4 +-- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index dbc68a2..eabdcb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SumSharp -A highly configurable C# discriminated union library +A highly configurable C\# discriminated union library [![NuGet](https://img.shields.io/nuget/v/SumSharp.svg)](https://www.nuget.org/packages/SumSharp) [![Build](https://github.com/christiandaley/SumSharp/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/christiandaley/SumSharp/actions) @@ -38,17 +38,17 @@ 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 that would be expected from true, language level discriminated union types. +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. -`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, Haskell, and Scala. Although it's impossible to exactly replicate the functionality that those other languages offer, `SumSharp` attempts to get as close as possible. +`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 - Unlimited number of cases -- Support for class, struct, record, and record struct union types -- Support for generic type cases +- Support for class, struct, record, and record struct unions +- Support for generic unions - Expressive match syntax with exhaustiveness checking -- Implicit conversions from types (as long as there's only one case of that type in the union) +- Implicit conversions from types (if there's only one case of that type in the union) - Convenient handling of tuple types - **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 @@ -183,7 +183,7 @@ let result = match myOptionalValue with | _ -> "" ``` -The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match`/`Switch` that is already exhaustive. +The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match`/`Switch` 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. --- @@ -653,7 +653,7 @@ The custom empty type is required to have a parameterless (default) constructor. 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. -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._ +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._ ### Disabling nullable annotations diff --git a/SumSharp.Analyzer/MatchAnalyzer.cs b/SumSharp.Analyzer/MatchAnalyzer.cs index d7d113a..c748b73 100644 --- a/SumSharp.Analyzer/MatchAnalyzer.cs +++ b/SumSharp.Analyzer/MatchAnalyzer.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -13,7 +14,7 @@ public class MatchAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor NonExhaustiveMatchRule = new DiagnosticDescriptor( "SumSharp0001", title: "Non-exhaustive match", - messageFormat: "Failure to handle cases: {0}. Handle all cases or provide a default case (_) handler", + messageFormat: "Failure to handle case(s): {0}. Handle all cases or provide a default case (_) handler", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -26,7 +27,15 @@ public class MatchAnalyzer : DiagnosticAnalyzer defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); - public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(NonExhaustiveMatchRule, RedundantDefaultCaseRule); + private static readonly DiagnosticDescriptor UnamedCaseHandlerRule = new DiagnosticDescriptor( + "SumSharp0003", + title: "Unnamed case handler", + messageFormat: "Handler for case(s) {0} specified by position rather than name. Consider specifying by name to make code clearer and prevent bugs/compilation errors if case ordering changes", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(NonExhaustiveMatchRule, RedundantDefaultCaseRule, UnamedCaseHandlerRule); public override void Initialize(AnalysisContext context) { @@ -53,16 +62,32 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - var caseNames = GetCaseNames(methodSymbol.ContainingType); + var caseNames = + methodSymbol.ContainingType.GetAttributes() + .Where(attr => attr.AttributeClass.Name == "UnionCaseAttribute") + .Select(attr => (string)attr.ConstructorArguments[0].Value!) + .ToArray(); if (caseNames.Length == 0) { return; } + var unnamedArgs = new List(); + var passedArgs = invocation.ArgumentList.Arguments - .Select((arg, i) => arg.NameColon?.Name.Identifier.Text ?? caseNames[i]) + .Select((arg, i) => + { + if (arg.NameColon is not null) + { + return arg.NameColon.Name.Identifier.Text; + } + + unnamedArgs.Add(caseNames[i]); + + return caseNames[i]; + }) .ToImmutableHashSet(); bool hasDefaultHandler = passedArgs.Contains("_"); @@ -86,14 +111,15 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(diagnostic); } - } - private string[] GetCaseNames(INamedTypeSymbol type) - { - return - type.GetAttributes() - .Where(attr => attr.AttributeClass.Name == "UnionCaseAttribute") - .Select(attr => (string)attr.ConstructorArguments[0].Value!) - .ToArray(); + if (unnamedArgs.Count > 0) + { + var diagnostic = Diagnostic.Create( + UnamedCaseHandlerRule, + invocation.GetLocation(), + $"[{string.Join(", ", unnamedArgs)}]"); + + context.ReportDiagnostic(diagnostic); + } } } diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 5aba39a..b750724 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,12 +13,12 @@ True - SumSharp0001;SumSharp0002 + SumSharp0001;SumSharp0002;SumSharp0003 True - SumSharp0001;SumSharp0002 + SumSharp0001;SumSharp0002;SumSharp0003 From a631736f78dc13f4b7c0abe5d9f9006784c9bbdd Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Tue, 5 Aug 2025 11:23:37 -0700 Subject: [PATCH 09/19] Replace switch with match (#18) * remove switch * update readme * fix null comparisons * update readme --- README.md | 22 ++-- SumSharp.Analyzer/MatchAnalyzer.cs | 8 +- SumSharp.Generator/SymbolHandler.cs | 112 ++++++---------- SumSharp/Internal/Box.cs | 4 +- SumSharp/MatchFailureException.cs | 2 +- Tests/MatchVoid.cs | 97 ++++++++++++++ Tests/Switch.cs | 194 ---------------------------- 7 files changed, 151 insertions(+), 288 deletions(-) create mode 100644 Tests/MatchVoid.cs delete mode 100644 Tests/Switch.cs diff --git a/README.md b/README.md index eabdcb9..2062d31 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ That's it! `SumSharp` will generate members for the `StringOrDouble` class that - `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 -- `Switch`, `Match`, `IfString`, and `IfDouble` functions for control flow +- `Match`, `IfString`, and `IfDouble` functions for control flow - An `Index` int property that reflects the current case - Implicit conversions from string/double to `StringOrDouble` - Implementation of the `IEquatable` interface, `Object.Equals` override, and `==` and `!=` operators to allow for value equality comparisons @@ -99,7 +99,7 @@ That's it! `SumSharp` will generate members for the `StringOrDouble` class that var x = StringOrDouble.Double(3.14); // Prints "Value is a double: 3.14" -x.Switch( +x.Match( String: s => Console.WriteLine($"Value is a string: {s}"), Double: d => Console.WriteLine($"Value is a double: {d}")); @@ -147,7 +147,7 @@ partial class Optional ### The `Match` function -Performing a "match" on a discriminated union for control flow is a common need. `SumSharp` unions have a `Match` member function that provides this functionality (`Switch` and its async overload provide equivalent functionality for void returning handlers). 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. +`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. ```csharp // Here myOptionalValue is an Optional @@ -165,7 +165,7 @@ let result = match myOptionalValue with | Some x -> x ``` -Handling each case is not required, but a warning will be emitted by the `SumSharp` analyzer if the handling is non-exhaustive. It can be a good idea to treat this warning as an error. A match or switch statement that fails to handle a case at runtime will throw a `SumSharp.MatchFailureException`. +Handling each case is not required, but a warning will be emitted by the `SumSharp` analyzer if the handling is non-exhaustive. It can be a good idea to treat this warning as an error. A `Match` that fails to handle a case at runtime will throw a `SumSharp.MatchFailureException`. If you only want to handle some subset of cases, you can provide a default handler to prevent a warning from being emitted. @@ -183,7 +183,7 @@ let result = match myOptionalValue with | _ -> "" ``` -The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match`/`Switch` 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. +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. --- @@ -402,7 +402,7 @@ You can also pass `GenericTypeInfo.ReferenceType` for generic types that you kno ### ValueTuple cases -If a case holds a `System.ValueTuple<...>`, an overload of the case constructor is generated that allows the individual tuple items to be passed as separate arguments. `Switch`, `Match`, and `If` case handler functions accept the items of the tuple as individual arguments rather than the tuple itself. +If a case holds a `System.ValueTuple<...>`, an overload of the case constructor is generated that allows the individual tuple items to be passed as separate arguments. `Match`, and `If` case handler functions accept the items of the tuple as individual arguments rather than the tuple itself. ```csharp [UnionCase("Case0", typeof((int, string)))] @@ -417,8 +417,8 @@ partial class UnionWithTuple // You can either pass a tuple or pass each tuple value as a separate argument var x = UnionWithTuple.Case0(5, "abc"); -// "Switch", "Match", and "If" function handlers work with the individual items rather than the tuple type itself -x.Switch( +// "Match", and "If" function handlers work with the individual items rather than the tuple type itself +x.Match( Case0: (i, s) => { Console.WriteLine(i); @@ -426,12 +426,6 @@ x.Switch( }, Case1: f => {}); -var s = x.Match( - Case0: (i, s) => s + i.ToString(), - Case1: f => f.ToString()); - -Console.WriteLine(s); - x.IfCase0((i, s) => { Console.WriteLine(i); diff --git a/SumSharp.Analyzer/MatchAnalyzer.cs b/SumSharp.Analyzer/MatchAnalyzer.cs index c748b73..12f9db5 100644 --- a/SumSharp.Analyzer/MatchAnalyzer.cs +++ b/SumSharp.Analyzer/MatchAnalyzer.cs @@ -14,7 +14,7 @@ public class MatchAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor NonExhaustiveMatchRule = new DiagnosticDescriptor( "SumSharp0001", title: "Non-exhaustive match", - messageFormat: "Failure to handle case(s): {0}. Handle all cases or provide a default case (_) handler", + messageFormat: "Match fails to handle case(s): {0}. Handle all cases or provide a default case (_) handler", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -22,7 +22,7 @@ public class MatchAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor RedundantDefaultCaseRule = new DiagnosticDescriptor( "SumSharp0002", title: "Redundant default case", - messageFormat: "All cases are handled. Default case handler will never be used", + messageFormat: "Match handles all cases. Default case handler will never be used", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -30,7 +30,7 @@ public class MatchAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor UnamedCaseHandlerRule = new DiagnosticDescriptor( "SumSharp0003", title: "Unnamed case handler", - messageFormat: "Handler for case(s) {0} specified by position rather than name. Consider specifying by name to make code clearer and prevent bugs/compilation errors if case ordering changes", + messageFormat: "Match handler for case(s) {0} specified by position rather than name. Consider specifying by name to make code clearer and prevent bugs/compilation errors if case ordering changes", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -57,7 +57,7 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - if (methodSymbol.Name != "Match" && methodSymbol.Name != "Switch") + if (methodSymbol.Name != "Match") { return; } diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 6b8727a..e08c5a4 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -226,6 +226,8 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public string NullableIfRef => NullableDisabled || IsStruct ? "" : "?"; + public string NullForgiving => NullableDisabled ? "" : "!"; + public string Accessibility { get; } public bool IsGenericType => TypeArguments.Length > 0; @@ -590,10 +592,6 @@ public string Emit() EmitIs(); - EmitSwitch(); - - EmitSwitchAsync(); - EmitMatch(); EmitIf(); @@ -782,7 +780,7 @@ public void EmitEquals() ///Compares two {XMLEscapedName} instances for equality. The two instances are equal iff they have the same Index and their underlying values compare equal using Object.Equals public bool Equals({Name}{NullableIfRef} other) {{ - {(IsStruct ? "" : "if (ReferenceEquals(null, other)) return false;")} + {(IsStruct ? "" : "if (other is null) return false;")} if (Index != other.Index) return false; return Index switch @@ -810,7 +808,7 @@ public bool Equals({Name}{NullableIfRef} other) /// the other object is a {XMLEscapedName} and they have the same Index and their underlying values compare equal using Object.Equals public override bool Equals(object{Nullable} obj) {{ - if (ReferenceEquals(null, obj)) return false; + if (obj is null) return false; {(IsStruct ? "" : "if (ReferenceEquals(this, obj)) return true;")} if (obj.GetType() != GetType()) return false; @@ -1015,13 +1013,15 @@ public void EmitIs() } } - private void EmitSwitch() + private void EmitMatch() { + // void returning match + Builder.Append($@" ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName}. Throws /// if no handler or default handler is provided for the active case ///Thrown when no handler or default handler is provided for the active case - public void Switch("); + public void Match("); Builder.Append(string.Join(", ", Cases.Select(caseData => { @@ -1065,63 +1065,12 @@ private void EmitSwitch() break;"); } - Builder.Append(@" + Builder.AppendLine(@" } }"); - } - - private void EmitSwitchAsync() - { - Builder.Append($@" - ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName}. Throws - /// if no handler or default handler is provided for the active case - ///Thrown when no handler or default handler is provided for the active case - public Task Switch("); - - Builder.Append(string.Join(", ", Cases.Select(caseData => - { - if (caseData.TypeInfo == null) - { - return $"Func{Nullable} {caseData.Name} = null"; - } - else if (caseData.TypeInfo.IsTupleType) - { - return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>{Nullable} {caseData.Name} = null"; - } - else - { - return $"Func<{caseData.TypeInfo.Name}, Task>{Nullable} {caseData.Name} = null"; - } - }))); - - Builder.Append($", Func{Nullable} _ = null)"); - - Builder.Append(@" - { - return Index switch - {"); - - foreach (var caseData in Cases) - { - var arg = - caseData.TypeInfo == null ? "" : - caseData.TypeInfo.IsTupleType ? - string.Join(", ", caseData.TypeInfo.TupleTypeArgs.Select((_, i) => $"As{caseData.Name}Unsafe.Item{i + 1}")) : - $"As{caseData.Name}Unsafe"; - - var throwException = $@"throw new global::SumSharp.MatchFailureException(""{caseData.Name}"")"; - Builder.Append($@" - {caseData.Index} => {caseData.Name} is not null ? {caseData.Name}({arg}) : _ is not null ? _() : {throwException},"); - } + // value returning match - Builder.Append(@" - }; - }"); - } - - private void EmitMatch() - { Builder.Append($@" ///Invokes the corresponding function with the underlying value held by the {XMLEscapedName} and returns the result. Throws /// if no handler or default handler is provided for the active case @@ -1344,7 +1293,7 @@ private void EmitToString() Builder.Append($@" public override string ToString() {{ - var valueString = Index switch + var (caseName, value) = Index switch {{"); foreach (var caseData in Cases) @@ -1352,24 +1301,29 @@ public override string ToString() if (caseData.TypeInfo == null) { Builder.Append($@" - {caseData.Index} => ""(empty)"","); + {caseData.Index} => (""{caseData.Name}"", null),"); } else if (caseData.TypeInfo.IsAlwaysValueType) { Builder.Append($@" - {caseData.Index} => As{caseData.Name}Unsafe.ToString(),"); + {caseData.Index} => (""{caseData.Name}"", As{caseData.Name}Unsafe.ToString()),"); + } + else if (caseData.TypeInfo.IsAlwaysRefType) + { + Builder.Append($@" + {caseData.Index} => (""{caseData.Name}"", As{caseData.Name}Unsafe is null ? ""null"" : As{caseData.Name}Unsafe.ToString()),"); } else { Builder.Append($@" - {caseData.Index} => ReferenceEquals(null, As{caseData.Name}Unsafe) ? ""null"" : As{caseData.Name}Unsafe.ToString(),"); + {caseData.Index} => (""{caseData.Name}"", typeof({caseData.TypeInfo.Name}).IsValueType ? As{caseData.Name}Unsafe{NullForgiving}.ToString() : ReferenceEquals(null, As{caseData.Name}Unsafe) ? ""null"" : As{caseData.Name}Unsafe.ToString()),"); } } Builder.AppendLine(@" }; - return $""{{ Index = {Index}, Value = {valueString} }}""; + return value is null ? caseName : $""{caseName} {value}""; } "); } @@ -1434,14 +1388,20 @@ public partial class StandardJsonConverter : System.Text.Json.Serialization.Json }} public override void Write(System.Text.Json.Utf8JsonWriter writer, {Name}{NullableIfRef} value, System.Text.Json.JsonSerializerOptions options) - {{ - if (ReferenceEquals(null, value)) + {{"); + + if (!IsStruct) + { + Builder.AppendLine($@" + if (value is null) {{ writer.WriteNullValue(); return; - }} + }}"); + } + Builder.Append($@" writer.WriteStartObject(); switch (value.Index) @@ -1540,14 +1500,20 @@ public partial class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter<{Na }} public override void WriteJson(Newtonsoft.Json.JsonWriter writer, {Name}{NullableIfRef} value, Newtonsoft.Json.JsonSerializer serializer) - {{ - if (ReferenceEquals(null, value)) + {{"); + + if (!IsStruct) + { + Builder.AppendLine($@" + if (value is null) {{ writer.WriteNull(); return; - }} + }}"); + } + Builder.Append($@" writer.WriteStartObject(); switch (value.Index) @@ -1646,7 +1612,7 @@ public override bool CanConvert(System.Type objectType) public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object{Nullable} value, Newtonsoft.Json.JsonSerializer serializer) {{ - if (ReferenceEquals(null, value)) + if (value is null) {{ writer.WriteNull(); diff --git a/SumSharp/Internal/Box.cs b/SumSharp/Internal/Box.cs index eb3b8b9..01f2b6b 100644 --- a/SumSharp/Internal/Box.cs +++ b/SumSharp/Internal/Box.cs @@ -8,7 +8,7 @@ public sealed class Box(T value) : IEquatable> public bool Equals(Box other) { - if (ReferenceEquals(null, other)) return false; + if (other is null) return false; if (ReferenceEquals(this, other)) return true; return Equals(Value, other.Value); @@ -16,7 +16,7 @@ public bool Equals(Box other) public override bool Equals(object obj) { - if (ReferenceEquals(null, obj)) return false; + if (obj is null) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != GetType()) return false; diff --git a/SumSharp/MatchFailureException.cs b/SumSharp/MatchFailureException.cs index aedde82..7560b77 100644 --- a/SumSharp/MatchFailureException.cs +++ b/SumSharp/MatchFailureException.cs @@ -3,7 +3,7 @@ namespace SumSharp; /// -/// Thrown when a Match or Switch invocation on a union lacks a handler for the active case +/// Thrown when a Match invocation on a union lacks a handler for the active case /// /// The name of the active case held by the union public sealed class MatchFailureException(string caseName) : Exception($"Failed to handle case {caseName}") diff --git a/Tests/MatchVoid.cs b/Tests/MatchVoid.cs new file mode 100644 index 0000000..783e37a --- /dev/null +++ b/Tests/MatchVoid.cs @@ -0,0 +1,97 @@ +namespace Tests; + +using SumSharp; + +public partial class MatchVoid +{ + + [UnionCase("Case0", typeof(int))] + [UnionCase("Case1")] + partial class IntOrNone + { + + } + + [UnionCase("Case0", typeof(ValueTuple))] + [UnionCase("Case1", "(T, T)")] + + partial class ContainsTuple + { + + } + + [UnionCase("Ok", "T")] + [UnionCase("Error", "E")] + partial class Result + { + + } + + [Fact] + public void Case0() + { + bool passed = false; + + IntOrNone.Case0(19).Match(value => { passed = value == 19; }, () => { }); + + Assert.True(passed); + } + + [Fact] + public void Case1() + { + bool passed = false; + + IntOrNone.Case1.Match(_ => { }, () => { passed = true; }); + + Assert.True(passed); + } + + [Fact] + public void TupleMatch() + { + var passed = false; + + ContainsTuple.Case0(true, 1).Match( + (b, i) => passed = b && i == 1, + (_, _) => { }); + + Assert.True(passed); + } + + [Fact] + public void NamedMatchNoDefault() + { + bool passed = false; + + Result.Ok("abc").Match( + Ok: str => passed = str == "abc", + Error: _ => { }); + + Assert.True(passed); + } + + [Fact] + public void NamedMatchWithDefault() + { + bool passed = false; + + Result.Error(new InvalidOperationException()).Match( + Ok: str => { }, + _: () => passed = true); + + Assert.True(passed); + } + + [Fact] + public void NonExhaustiveMatch() + { + var err = Assert.Throws(() => + { + Result.Ok("abc").Match( + Error: _ => { }); + }); + + Assert.Equal("Ok", err.CaseName); + } +} \ No newline at end of file diff --git a/Tests/Switch.cs b/Tests/Switch.cs deleted file mode 100644 index f98e617..0000000 --- a/Tests/Switch.cs +++ /dev/null @@ -1,194 +0,0 @@ -namespace Tests; - -using SumSharp; - -public partial class Switch -{ - - [UnionCase("Case0", typeof(int))] - [UnionCase("Case1")] - partial class IntOrNone - { - - } - - [UnionCase("Case0", typeof(ValueTuple))] - [UnionCase("Case1", "(T, T)")] - - partial class ContainsTuple - { - - } - - [UnionCase("Ok", "T")] - [UnionCase("Error", "E")] - partial class Result - { - - } - - [Fact] - public void Case0() - { - bool passed = false; - - IntOrNone.Case0(19).Switch(value => { passed = value == 19; }, () => { }); - - Assert.True(passed); - } - - [Fact] - public async Task Case0Async() - { - bool passed = false; - - await IntOrNone.Case0(3).Switch( - value => - { - passed = value == 3; - - return Task.CompletedTask; - }, - () => Task.CompletedTask); - - Assert.True(passed); - } - - [Fact] - public void Case1() - { - bool passed = false; - - IntOrNone.Case1.Switch(_ => { }, () => { passed = true; }); - - Assert.True(passed); - } - - [Fact] - public async Task Case1Async() - { - bool passed = false; - - await IntOrNone.Case1.Switch( - _ => Task.CompletedTask, - () => - { - passed = true; - - return Task.CompletedTask; - }); - - Assert.True(passed); - } - - [Fact] - public void TupleSwitch() - { - var passed = false; - - ContainsTuple.Case0(true, 1).Switch( - (b, i) => passed = b && i == 1, - (_, _) => { }); - - Assert.True(passed); - } - - - [Fact] - public async Task TupleSwitchAsync() - { - var passed = false; - - await ContainsTuple.Case1("a", "b").Switch( - (_, _) => Task.CompletedTask, - (s1, s2) => - { - passed = s1 == "a" && s2 == "b"; - - return Task.CompletedTask; - }); - - Assert.True(passed); - } - - [Fact] - public void NamedSwitchNoDefault() - { - bool passed = false; - - Result.Ok("abc").Switch( - Ok: str => passed = str == "abc", - Error: _ => { }); - - Assert.True(passed); - } - - [Fact] - public async Task NamedSwitchNoDefaultAsync() - { - bool passed = false; - - await Result.Ok("abc").Switch( - Ok: str => - { - passed = str == "abc"; - - return Task.CompletedTask; - }, - Error: _ => Task.CompletedTask); - - Assert.True(passed); - } - - [Fact] - public void NamedSwitchWithDefault() - { - bool passed = false; - - Result.Error(new InvalidOperationException()).Switch( - Ok: str => { }, - _: () => passed = true); - - Assert.True(passed); - } - - [Fact] - public async Task NamedSwitchWithDefaultAsync() - { - bool passed = false; - - await Result.Error(new Exception()).Switch( - Ok: str => Task.CompletedTask, - _: () => - { - passed = true; - return Task.CompletedTask; - }); - - Assert.True(passed); - } - - [Fact] - public void NonExhaustiveSwitch() - { - var err = Assert.Throws(() => - { - Result.Ok("abc").Switch( - Error: _ => { }); - }); - - Assert.Equal("Ok", err.CaseName); - } - - [Fact] - public async Task NonExhaustiveSwitchAsync() - { - var err = await Assert.ThrowsAsync(async () => - { - await Result.Error(new Exception()).Switch( - Ok: _ => Task.CompletedTask); - }); - - Assert.Equal("Error", err.CaseName); - } -} \ No newline at end of file From 7c4bcea67a34e3fb27fc8e35b3c14eb623feacc4 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sun, 31 Aug 2025 12:08:11 -0700 Subject: [PATCH 10/19] use fullyqualified type names (#19) --- SumSharp.Generator/SymbolHandler.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index e08c5a4..94b9574 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -35,7 +35,7 @@ public abstract class TypeInfo public class NonArray(INamedTypeSymbol symbol) : TypeInfo { - public override string Name { get; } = symbol.ToDisplayString(); + public override string Name { get; } = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); public override bool IsUnmanaged => symbol.IsUnmanagedType; @@ -54,7 +54,7 @@ public class NonArray(INamedTypeSymbol symbol) : TypeInfo public class Array(IArrayTypeSymbol symbol) : TypeInfo { - public override string Name { get; } = symbol.ToDisplayString(); + public override string Name { get; } = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); public override bool IsUnmanaged => false; @@ -466,7 +466,7 @@ public SymbolHandler( if (enableOneOfConversionsData.ConstructorArguments.Length == 1) { - OneOfEmptyCase = ((ITypeSymbol)enableOneOfConversionsData.ConstructorArguments[0].Value!).ToDisplayString(); + OneOfEmptyCase = ((ITypeSymbol)enableOneOfConversionsData.ConstructorArguments[0].Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); } } From 3b849fca388f9f27fe68ada0308c0e8b3ab366e5 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Wed, 29 Jul 2026 11:22:09 -0700 Subject: [PATCH 11/19] add system. namespace specification (#23) --- SumSharp.Generator/SymbolHandler.cs | 50 ++++++++++++++--------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 94b9574..9140411 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -695,7 +695,7 @@ private void EmitFieldsAndConstructor() } Builder.Append($@" -{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(DisableValueEquality ? "" : $" : IEquatable<{Name}>")} +{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(DisableValueEquality ? "" : $" : System.IEquatable<{Name}>")} {{"); foreach (var field in fieldNameTypeMap) @@ -838,10 +838,10 @@ public override int GetHashCode() }}; }} - ///Compares two {XMLEscapedName} instances for equality using IEquatable<{XMLEscapedName}>.Equals + ///Compares two {XMLEscapedName} instances for equality using System.IEquatable<{XMLEscapedName}>.Equals public static bool operator==({Name} left, {Name} right) => left.Equals(right); - ///Compares two {XMLEscapedName} instances for inequality using IEquatable<{XMLEscapedName}>.Equals + ///Compares two {XMLEscapedName} instances for inequality using System.IEquatable<{XMLEscapedName}>.Equals public static bool operator!=({Name} left, {Name} right) => !left.Equals(right);"); } private void EmitCaseConstructors() @@ -995,12 +995,12 @@ public void EmitAs() Builder.AppendLine($@" ///Returns the {caseData.Name} value, if present. Otherwise returns the result of invoking ///Provides the default value to return if the {XMLEscapedName} does not hold a {caseData.Name} - public {caseData.TypeInfo.Name} As{caseData.Name}Or(Func<{caseData.TypeInfo.Name}> defaultValueFactory) => Index == {caseData.Index} ? As{caseData.Name}Unsafe : defaultValueFactory();"); + public {caseData.TypeInfo.Name} As{caseData.Name}Or(System.Func<{caseData.TypeInfo.Name}> defaultValueFactory) => Index == {caseData.Index} ? As{caseData.Name}Unsafe : defaultValueFactory();"); Builder.AppendLine($@" ///Returns a ValueTask wrapping the {caseData.Name} value, if present. Otherwise returns the result of invoking ///Provides the default value to return if the {XMLEscapedName} does not hold a {caseData.Name} - public ValueTask<{caseData.TypeInfo.Name}> As{caseData.Name}Or(Func> defaultValueFactory) => Index == {caseData.Index} ? ValueTask.FromResult(As{caseData.Name}Unsafe) : new ValueTask<{caseData.TypeInfo.Name}>(defaultValueFactory());"); + 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() @@ -1027,19 +1027,19 @@ private void EmitMatch() { if (caseData.TypeInfo == null) { - return $"Action{Nullable} {caseData.Name} = null"; + return $"System.Action{Nullable} {caseData.Name} = null"; } else if (caseData.TypeInfo.IsTupleType) { - return $"Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}>{Nullable} {caseData.Name} = null"; + return $"System.Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}>{Nullable} {caseData.Name} = null"; } else { - return $"Action<{caseData.TypeInfo.Name}>{Nullable} {caseData.Name} = null"; + return $"System.Action<{caseData.TypeInfo.Name}>{Nullable} {caseData.Name} = null"; } }))); - Builder.Append($", Action{Nullable} _ = null)"); + Builder.Append($", System.Action{Nullable} _ = null)"); Builder.Append(@" { @@ -1081,19 +1081,19 @@ private void EmitMatch() { if (caseData.TypeInfo == null) { - return $"Func{Nullable} {caseData.Name} = null"; + return $"System.Func{Nullable} {caseData.Name} = null"; } else if (caseData.TypeInfo.IsTupleType) { - return $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>{Nullable} {caseData.Name} = null"; + return $"System.Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>{Nullable} {caseData.Name} = null"; } else { - return $"Func<{caseData.TypeInfo.Name}, TRet_>{Nullable} {caseData.Name} = null"; + return $"System.Func<{caseData.TypeInfo.Name}, TRet_>{Nullable} {caseData.Name} = null"; } }))); - Builder.Append($", Func{Nullable} _ = null)"); + Builder.Append($", System.Func{Nullable} _ = null)"); Builder.Append(@" { @@ -1129,13 +1129,13 @@ private void EmitIf() var actionArgType = caseData.TypeInfo.IsTupleType ? - $"Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}>" : - $"Action<{caseData.TypeInfo.Name}>"; + $"System.Action<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}>" : + $"System.Action<{caseData.TypeInfo.Name}>"; var funcArgType = caseData.TypeInfo.IsTupleType ? - $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>" : - $"Func<{caseData.TypeInfo.Name}, TRet_>"; + $"System.Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, TRet_>" : + $"System.Func<{caseData.TypeInfo.Name}, TRet_>"; var handlerName = $"handle{caseData.Name}"; @@ -1163,7 +1163,7 @@ private void EmitIf() ///{caseData.TypeInfo.Name} value, otherwise invokes . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Function to be invoked if the {XMLEscapedName} does not hold a {caseData.Name} - public void If{caseData.Name}Else({actionArgType} {handlerName}, Action orElse) + public void If{caseData.Name}Else({actionArgType} {handlerName}, System.Action orElse) {{ if (Index == {caseData.Index}) {{ @@ -1187,7 +1187,7 @@ private void EmitIf() ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Produces the value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} - public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, Func elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); + public TRet_ If{caseData.Name}Else({funcArgType} {handlerName}, System.Func elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } } @@ -1203,13 +1203,13 @@ private void EmitIfAsync() var actionArgType = caseData.TypeInfo.IsTupleType ? - $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : - $"Func<{caseData.TypeInfo.Name}, Task>"; + $"System.Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : + $"System.Func<{caseData.TypeInfo.Name}, Task>"; var funcArgType = caseData.TypeInfo.IsTupleType ? - $"Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : - $"Func<{caseData.TypeInfo.Name}, Task>"; + $"System.Func<{string.Join(", ", caseData.TypeInfo.TupleTypeArgs)}, Task>" : + $"System.Func<{caseData.TypeInfo.Name}, Task>"; var handlerName = $"{caseData.Name}Handler"; @@ -1230,7 +1230,7 @@ private void EmitIfAsync() ///{caseData.TypeInfo.Name} value, otherwise invokes orElse. ///Function to be invoked with the {caseData.Name} value, if it exists. ///Function to be invoked if the {XMLEscapedName} does not hold a {caseData.Name} - public Task If{caseData.Name}Else({actionArgType} {handlerName}, Func elseF) => Index == {caseData.Index} ? {invokeHandler} : elseF();"); + public Task If{caseData.Name}Else({actionArgType} {handlerName}, System.Func elseF) => Index == {caseData.Index} ? {invokeHandler} : elseF();"); Builder.AppendLine($@" ///If the {XMLEscapedName} holds a {caseData.Name}, returns the result of invoking the @@ -1244,7 +1244,7 @@ private void EmitIfAsync() ///function with the {caseData.TypeInfo.Name} value, otherwise returns the result of invoking . ///Function to be invoked with the {caseData.Name} value, if it exists. ///Produces the value to be returned if the {XMLEscapedName} does not hold a {caseData.Name} - public Task If{caseData.Name}Else({funcArgType} {handlerName}, Func> elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); + public Task If{caseData.Name}Else({funcArgType} {handlerName}, System.Func> elseFunc) => Index == {caseData.Index} ? {invokeHandler} : elseFunc();"); } } From 92a7c0f2f96f77e7124a7b4fdd8ee8e47551f08c Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Wed, 29 Jul 2026 12:23:58 -0700 Subject: [PATCH 12/19] Dont rely on implicit usings (#24) * remove implicit usings from test project and make code compile * update readme --- README.md | 2 ++ SumSharp.Generator/SymbolHandler.cs | 6 +++--- Tests.AOT/Program.cs | 3 +++ Tests.AOT/Tests.AOT.csproj | 2 +- Tests/As.cs | 7 ++++++- Tests/Equals.cs | 2 ++ Tests/If.cs | 7 ++++++- Tests/ImplicitConversions.cs | 2 ++ Tests/Match.cs | 2 ++ Tests/MatchVoid.cs | 1 + Tests/NewtonsoftJsonSerialization.cs | 3 ++- Tests/OneOfConversions.cs | 2 +- Tests/StandardJsonSerialization.cs | 5 +++-- Tests/StandardJsonSerializationWithSourceGen.cs | 5 +++-- Tests/Storage.cs | 5 +++-- Tests/Tests.csproj | 2 +- 16 files changed, 41 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2062d31..bd85f04 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ partial class Optional } ``` +Note that generic types in general *must be fully qualified names unless you have implicit usings enabled in your project*. For example, using `List` may not compile, and `System.Collections.Generic.List` will need to be used instead. + ### 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. diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 9140411..3193d82 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -73,7 +73,7 @@ public class Array(IArrayTypeSymbol symbol) : TypeInfo public class SimpleGenericTypeArgument(ITypeParameterSymbol symbol, bool useUnmanagedStorage) : TypeInfo { - public override string Name => symbol.Name; + public override string Name => symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); public override bool IsUnmanaged => useUnmanagedStorage; @@ -752,7 +752,7 @@ static void CheckUnmanagedStorage() where TUnmanaged__ : unmanaged if (UnmanagedStorageSize < requiredStorage) {{ - throw new ArgumentException($""The unmanaged type {{typeof(TUnmanaged__).Name}} requires {{requiredStorage}} bytes of storage but {{typeof({Name}).Name}} has only {{UnmanagedStorageSize}} bytes available to store unmanaged types""); + throw new System.ArgumentException($""The unmanaged type {{typeof(TUnmanaged__).Name}} requires {{requiredStorage}} bytes of storage but {{typeof({Name}).Name}} has only {{UnmanagedStorageSize}} bytes available to store unmanaged types""); }} }}"); } @@ -981,7 +981,7 @@ public void EmitAs() Builder.AppendLine($@" ///The {caseData.Name} value, if present. Throws InvalidOperationException if the {XMLEscapedName} does not hold a {caseData.Name} ///Thrown if the {XMLEscapedName} does not hold a {caseData.Name} - public {caseData.TypeInfo.Name} As{caseData.Name} => Index == {caseData.Index} ? As{caseData.Name}Unsafe : throw new InvalidOperationException($""Attempted to access case index {caseData.Index} but index is {{Index}}"");"); + public {caseData.TypeInfo.Name} As{caseData.Name} => Index == {caseData.Index} ? As{caseData.Name}Unsafe : throw new System.InvalidOperationException($""Attempted to access case index {caseData.Index} but index is {{Index}}"");"); Builder.AppendLine($@" ///The {caseData.Name} value, if present. Otherwise default({caseData.TypeInfo.Name}) diff --git a/Tests.AOT/Program.cs b/Tests.AOT/Program.cs index 9358b35..657bf06 100644 --- a/Tests.AOT/Program.cs +++ b/Tests.AOT/Program.cs @@ -2,9 +2,12 @@ using SumSharp; +using System; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Linq; namespace Tests.AOT; diff --git a/Tests.AOT/Tests.AOT.csproj b/Tests.AOT/Tests.AOT.csproj index 9cd1b59..5be5756 100644 --- a/Tests.AOT/Tests.AOT.csproj +++ b/Tests.AOT/Tests.AOT.csproj @@ -3,7 +3,7 @@ Exe net8.0 - enable + disable enable true full diff --git a/Tests/As.cs b/Tests/As.cs index d099718..f7034c6 100644 --- a/Tests/As.cs +++ b/Tests/As.cs @@ -2,6 +2,11 @@ namespace Tests; using SumSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + public partial class As { @@ -20,7 +25,7 @@ partial class IntOrNone } [UnionCase("Case0", typeof((int IntValue, double DoubleValue)))] - [UnionCase("Case1", "(Dictionary> DictValue, (T, U) GenericTupleValue)")] + [UnionCase("Case1", "(System.Collections.Generic.Dictionary> DictValue, (T, U) GenericTupleValue)")] partial class ContainsTuple where T : notnull { diff --git a/Tests/Equals.cs b/Tests/Equals.cs index f29f2a1..145c6ec 100644 --- a/Tests/Equals.cs +++ b/Tests/Equals.cs @@ -4,6 +4,8 @@ namespace Tests; using SumSharp; +using System; + public partial class Equals { diff --git a/Tests/If.cs b/Tests/If.cs index c5b5e63..d272443 100644 --- a/Tests/If.cs +++ b/Tests/If.cs @@ -2,6 +2,11 @@ namespace Tests; using SumSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + public partial class If { @@ -16,7 +21,7 @@ partial class DoubleOrNone [UnionCase("Case0", typeof((int, double)))] [UnionCase("Case1", typeof(ValueTuple))] [UnionCase("Case2", "(T, U)")] - [UnionCase("Case3", "ValueTuple>, (T, U)>")] + [UnionCase("Case3", "System.ValueTuple>, (T, U)>")] partial class ContainsTuple where T : notnull { diff --git a/Tests/ImplicitConversions.cs b/Tests/ImplicitConversions.cs index 84f017f..a61814a 100644 --- a/Tests/ImplicitConversions.cs +++ b/Tests/ImplicitConversions.cs @@ -2,6 +2,8 @@ namespace Tests; using SumSharp; +using System.Collections.Generic; + public partial class Conversions { diff --git a/Tests/Match.cs b/Tests/Match.cs index 7824e8f..e646fe5 100644 --- a/Tests/Match.cs +++ b/Tests/Match.cs @@ -2,6 +2,8 @@ namespace Tests; using SumSharp; +using System; +using System.Threading.Tasks; public partial class Match { diff --git a/Tests/MatchVoid.cs b/Tests/MatchVoid.cs index 783e37a..50857b3 100644 --- a/Tests/MatchVoid.cs +++ b/Tests/MatchVoid.cs @@ -1,6 +1,7 @@ namespace Tests; using SumSharp; +using System; public partial class MatchVoid { diff --git a/Tests/NewtonsoftJsonSerialization.cs b/Tests/NewtonsoftJsonSerialization.cs index 3c88157..3fe52df 100644 --- a/Tests/NewtonsoftJsonSerialization.cs +++ b/Tests/NewtonsoftJsonSerialization.cs @@ -3,6 +3,7 @@ namespace Tests; using SumSharp; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Collections.Generic; public partial class NewtonsoftJsonSerialization { @@ -23,7 +24,7 @@ public record NestedRecord2(string Arg1); [UnionCase("Case0", "T")] [UnionCase("Case1", "U[]")] - [UnionCase("Case2", "GenericType, U>")] + [UnionCase("Case2", "GenericType, U>")] [EnableJsonSerialization(JsonSerializationSupport.Newtonsoft)] partial struct GenericType where T : notnull diff --git a/Tests/OneOfConversions.cs b/Tests/OneOfConversions.cs index 8e01273..0489d1b 100644 --- a/Tests/OneOfConversions.cs +++ b/Tests/OneOfConversions.cs @@ -1,7 +1,7 @@ namespace Tests; using SumSharp; -using Newtonsoft.Json.Linq; +using System.Collections.Generic; using OneOf; using OneOf.Types; diff --git a/Tests/StandardJsonSerialization.cs b/Tests/StandardJsonSerialization.cs index 4f7d0f6..72d22ba 100644 --- a/Tests/StandardJsonSerialization.cs +++ b/Tests/StandardJsonSerialization.cs @@ -3,7 +3,8 @@ namespace Tests; using SumSharp; using System.Text.Json; using System.Text.Json.Nodes; - +using System; +using System.Collections.Generic; public partial class StandardJsonSerialization { @@ -24,7 +25,7 @@ public record NestedRecord2(string Arg1); [UnionCase("Case0", "T")] [UnionCase("Case1", "U[]")] - [UnionCase("Case2", "GenericType, U>")] + [UnionCase("Case2", "GenericType, U>")] [EnableJsonSerialization] partial class GenericType where T : notnull diff --git a/Tests/StandardJsonSerializationWithSourceGen.cs b/Tests/StandardJsonSerializationWithSourceGen.cs index 0539624..b7be322 100644 --- a/Tests/StandardJsonSerializationWithSourceGen.cs +++ b/Tests/StandardJsonSerializationWithSourceGen.cs @@ -1,7 +1,8 @@ namespace Tests; using SumSharp; -using System.Diagnostics.Metrics; +using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -45,7 +46,7 @@ public partial class StandardJsonConverter : JsonConverter [UnionCase("Case0", "T")] [UnionCase("Case1", "U[]")] - [UnionCase("Case2", "GenericType, U>")] + [UnionCase("Case2", "GenericType, U>")] [EnableJsonSerialization(AddJsonConverterAttribute: false)] [JsonConverter(typeof(GenericType.StandardJsonConverter))] internal partial class GenericType diff --git a/Tests/Storage.cs b/Tests/Storage.cs index cdc7679..51979d2 100644 --- a/Tests/Storage.cs +++ b/Tests/Storage.cs @@ -2,6 +2,7 @@ namespace Tests; +using System; using System.Reflection; using System.Runtime.CompilerServices; using SumSharp; @@ -114,9 +115,9 @@ public partial struct NestedGeneric where W : class } } - [UnionCase("Case0", "Dictionary", GenericTypeInfo: GenericTypeInfo.ReferenceType)] + [UnionCase("Case0", "System.Collections.Generic.Dictionary", GenericTypeInfo: GenericTypeInfo.ReferenceType)] [UnionCase("Case1", "InnerStruct", GenericTypeInfo: GenericTypeInfo.ValueType)] - [UnionCase("Case2", "IEnumerable", IsInterface: true)] + [UnionCase("Case2", "System.Collections.Generic.IEnumerable", IsInterface: true)] [UnionCase("Case3", "InnerStruct", GenericTypeInfo: GenericTypeInfo.ValueType)] [UnionCase("Case4", "InnerClass", GenericTypeInfo: GenericTypeInfo.ReferenceType)] partial class GenericWithTypeInfo diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index b750724..08e8293 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -2,7 +2,7 @@ net8.0 - enable + disable enable false From c890acb01aafc3478e40a815b639c8920d43430a Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Wed, 29 Jul 2026 12:44:33 -0700 Subject: [PATCH 13/19] fix case naming bug (#25) --- SumSharp.Generator/SymbolHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 3193d82..c926b87 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -1423,7 +1423,7 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, {Name}{Nullab writer.WritePropertyName(""{caseData.Index}"");"); Builder.AppendLine($@" - System.Text.Json.JsonSerializer.Serialize(writer, value.AsCase{caseData.Index}Unsafe, options);"); + System.Text.Json.JsonSerializer.Serialize(writer, value.As{caseData.Name}Unsafe, options);"); } Builder.Append($@" @@ -1535,7 +1535,7 @@ public override void WriteJson(Newtonsoft.Json.JsonWriter writer, {Name}{Nullabl else { Builder.AppendLine($@" - serializer.Serialize(writer, value.AsCase{caseData.Index}Unsafe);"); + serializer.Serialize(writer, value.As{caseData.Name}Unsafe);"); } Builder.Append($@" From 4bbac3438109f9779b4d4a3a1d8f01b98843944b Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Thu, 30 Jul 2026 13:01:47 -0700 Subject: [PATCH 14/19] Upgrade to dotnet 10 (#27) * use dotnet 10 for tests * only run dotnet10 build on ci * suppress IL warnings --- .github/workflows/build-and-test.yml | 2 +- SumSharp.Generator/SymbolHandler.cs | 8 ++++++++ Tests.AOT/Tests.AOT.csproj | 2 +- Tests/Tests.csproj | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3777575..c2e253a 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 diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index c926b87..8c24024 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -13,6 +13,9 @@ 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.\")]"; + public abstract class TypeInfo { public abstract string Name { get; } @@ -1334,6 +1337,8 @@ private void EmitStandardJsonConverter() ///System.Text.Json converter capable of serializing and deserializing a {XMLEscapedName} 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 +1392,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) {{"); @@ -1567,6 +1574,7 @@ private void EmitStandardJsonConverterFactory() Builder.Append($@" ///System.Text.Json converter capable of serializing and deserializing any {NameWithoutTypeArguments} + {(UsingAOTCompilation ? IL3050SupressAttribute : "")} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverterFactory {{ public override bool CanConvert(System.Type typeToConvert) 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/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 From fc8229356a26cef3b217893481d9969b3460bb8b Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Thu, 30 Jul 2026 13:06:27 -0700 Subject: [PATCH 15/19] update publish job to use dotnet 10 (#28) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 1549798e7dfecdb3d25119f926b677dc31ea40f2 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Thu, 30 Jul 2026 16:33:14 -0700 Subject: [PATCH 16/19] Add idisposable and iasyncdisposable implementations (#29) * add disposable test * make test compile * make test pass * add test for generic idisposable * make generic test pass * small change * add disposeasync test * make test compile * make test pass * add test for combined dispose and disposeasync * make test pass * add another test * small change * test sealed class * add another test * update readme * fix readme * fix test * small improvement * small improvement * small improvement --- README.md | 49 ++++++ SumSharp.Generator/SymbolHandler.cs | 164 ++++++++++++++++++- Tests/Dispose.cs | 244 ++++++++++++++++++++++++++++ Tests/Storage.cs | 4 +- 4 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 Tests/Dispose.cs diff --git a/README.md b/README.md index bd85f04..679c667 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A highly configurable C\# discriminated union library 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) @@ -50,6 +51,7 @@ There are many discriminated union libraries available for C\#, such as [`OneOf` - 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 @@ -437,6 +439,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.** diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 8c24024..d0f5742 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -36,6 +36,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); @@ -53,6 +57,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 @@ -281,6 +289,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, @@ -478,6 +492,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) @@ -610,6 +630,16 @@ public string Emit() EmitToString(); + if (IsDisposable) + { + EmitDispose(); + } + + if (IsAsyncDisposable) + { + EmitDisposeAsync(); + } + if (EnableStandardJsonSerialization) { EmitStandardJsonConverter(); @@ -697,8 +727,23 @@ private void EmitFieldsAndConstructor() fieldNameTypeMap[caseData.FieldType!] = caseData.FieldName!; } + List interfaces = []; + + if (!DisableValueEquality) + { + interfaces.Add($"System.IEquatable<{Name}>"); + } + if (IsDisposable) + { + interfaces.Add("System.IDisposable"); + } + if (IsAsyncDisposable) + { + interfaces.Add("System.IAsyncDisposable"); + } + Builder.Append($@" -{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(DisableValueEquality ? "" : $" : System.IEquatable<{Name}>")} +{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(interfaces.Count == 0 ? "" : $" : {string.Join(", ", interfaces)}")} {{"); foreach (var field in fieldNameTypeMap) @@ -707,6 +752,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 @@ -1331,6 +1382,117 @@ 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($@" 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/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] From 959e535efacbb9b00ee3ed49bf53d6287d58fa12 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sat, 1 Aug 2026 19:19:58 -0700 Subject: [PATCH 17/19] Add equals operators for underlying value types (#30) * add test case * make test compile * update test * update tests again * improve implementation * disable operators for .net 8 and earlier * add comment * update test * add documentation * implement not generic equality operators * add test for generic type equality * improve test * make test pass * refactor implementation * get test passing * small change to box * update readme --- README.md | 4 +- SumSharp.Generator/SymbolHandler.cs | 130 ++++++++++++++++++++++++++-- SumSharp/Internal/Box.cs | 3 +- Tests/Equals.cs | 62 +++++++++++++ 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 679c667..963deb9 100644 --- a/README.md +++ b/README.md @@ -696,7 +696,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 d0f5742..fc79579 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -255,6 +255,8 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public CaseData[] UniqueCases { get; } + public TypeInfo[] DistinctTypes { get; } + public INamedTypeSymbol[] ContainingTypes; public bool HasGenericContainingTypes => ContainingTypes.Any(type => type.TypeArguments.Length > 0); @@ -417,13 +419,21 @@ public SymbolHandler( }) .ToArray(); - var distinctTypes = - Cases - .Where(caseData => caseData.TypeInfo != null) - .Select(caseData => caseData.TypeInfo!.Name) - .Distinct(); + var typeMap = new Dictionary(); + + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + continue; + } - if (storageStrategy == 0 && distinctTypes.Count() == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) + typeMap[caseData.TypeInfo.Name] = caseData.TypeInfo; + } + + DistinctTypes = [..typeMap.Values]; + + if (storageStrategy == 0 && DistinctTypes.Length == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) { Cases = [.. Cases.Select(caseData => new CaseData(caseData.Index, caseData.Name, caseData.TypeInfo, false, caseData.StorageMode, FullUnmanagedStorageTypeName))]; } @@ -897,6 +907,114 @@ 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 type in DistinctTypes) + { + Builder.Append($@" + ///Compares a {XMLEscapedName} with a for equality using on the underlying value + public static bool operator==({Name} left, {type.Name} right) + {{ + return left.Index switch + {{"); + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.Append($@" + {caseData.Index} => false,"); + + continue; + } + + switch (type.IsGeneric, caseData.TypeInfo.IsGeneric) + { + case (false, false): + + if (caseData.TypeInfo.IsAlwaysValueType) + { + Builder.Append($@" + {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); + } + else + { + Builder.Append($@" + {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe is null ? right is null : left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); + } + + break; + case (false, true): + + if (type.IsAlwaysValueType) + { + Builder.Append($@" + {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right),"); + } + else + { + Builder.Append($@" + {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && (ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right)),"); + } + + break; + case (true, false): + + Builder.Append($@" + {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe.Equals(right),"); + + break; + + case (true, true): + { + var expression = new List(); + + if (caseData.TypeInfo.Name != type.Name) + { + expression.Add($"typeof({caseData.TypeInfo.Name}) == typeof({type.Name})"); + } + if (caseData.TypeInfo.IsAlwaysValueType || type.IsAlwaysValueType) + { + expression.Add($"left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right)"); + } + else + { + expression.Add($"(ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right))"); + } + + Builder.Append($@" + {caseData.Index} => {string.Join(" && ", expression)},"); + + } + break; + } + } + + Builder.AppendLine($@" + }}; + }} + ///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() { 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/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 From 0cf951820ff67e7536cb8941b767ff5311a7fb41 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sat, 8 Aug 2026 16:16:58 -0700 Subject: [PATCH 18/19] add support for .NET 11 union types --- .github/workflows/build-and-test.yml | 10 +- README.md | 68 ++++- SumSharp.Generator/SymbolHandler.cs | 435 +++++++++++++++++++-------- SumSharp.Generator/TypeNameParser.cs | 154 ++++++++++ SumSharp.sln | 10 +- Tests.Net11/Tests.Net11.csproj | 28 ++ Tests.Net11/Union.cs | 204 +++++++++++++ 7 files changed, 777 insertions(+), 132 deletions(-) create mode 100644 SumSharp.Generator/TypeNameParser.cs create mode 100644 Tests.Net11/Tests.Net11.csproj create mode 100644 Tests.Net11/Union.cs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c2e253a..6e3d434 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -26,10 +26,16 @@ jobs: dotnet-version: ${{ matrix.dotnet }} - name: Restore dependencies - run: dotnet restore +# run: dotnet restore + run: | + dotnet restore ./Tests/Tests.csproj + dotnet restore ./Tests.AOT/Tests.AOT.csproj - name: Build solution - run: dotnet build --configuration Release --no-restore +# run: dotnet build --configuration Release --no-restore + run: | + dotnet build ./Tests/Tests.csproj --configuration Release --no-restore + dotnet build ./Tests.AOT/Tests.AOT.csproj --configuration Release --no-restore - name: Run unit tests run: dotnet test ./Tests/Tests.csproj --configuration Release --no-build --verbosity normal diff --git a/README.md b/README.md index 963deb9..5dd59df 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,16 @@ A highly configurable C\# discriminated union library --- -1. [Why use `SumSharp`?](#why-use-sumsharp) +1. [Why use SumSharp?](#why-use-sumsharp) 2. [Installation](#installation) 3. [Quick start](#quick-start) - [Creating a DU type](#creating-a-du-type) - [Empty cases](#empty-cases) - [Generic cases](#generic-cases) - [The `Match` function](#the-match-function) + - [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching) 4. [Motivation](#motivation) + - [SumSharp vs .NET 11 union types](#sumsharp-vs-net-11-union-types) - [What about `OneOf`?](#what-about-oneof) - [Typical DU implementation approaches](#typical-du-implementation-approaches) - [SumSharp's approach](#sumsharps-approach) @@ -39,12 +41,13 @@ A highly configurable C\# discriminated union library Discriminated unions, also known as sum types, are an invaluable tool for working with heterogenous data types in code. They help ensure safe data access patterns and can [make illegal states unrepresentable.](https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/) -There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages. +There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages. Union types are being added to C# with the [.NET 11 release](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union), but these are not true DUs because they lack case names and thus cannot support multiple cases of the same type. `SumSharp` aims to be **the most powerful, expressive, and configurable C\# discriminated union library available**. Its goal is to provide features and syntax comparable to the discriminated union types natively offered by languages such as F\#, Rust, and Haskell. Although it's impossible to exactly replicate the functionality these other languages offer, `SumSharp` strives to get as close as possible. ### Features +- **Integration with .NET 11 union types, allowing use of C#'s built-in pattern matching syntax** - Unlimited number of cases - Support for class, struct, record, and record struct unions - Support for generic unions @@ -88,6 +91,7 @@ partial class StringOrDouble That's it! `SumSharp` will generate members for the `StringOrDouble` class that allow it to be used as a discriminated union type. These members include: +- `Value` and `HasValue` properties, and `TryGetValue` methods to satisfy requirements for a non-boxing .NET 11 union type - `String` and `Double` static functions that construct instances of `StringOrDouble` - `AsString` and `AsDouble` properties that return either the underlying string/double value or throw an `InvalidOperationException` - `IsString` and `IsDouble` boolean properties @@ -141,7 +145,7 @@ Case types can be generic. To define a generic case you must supply the **name** ```csharp [UnionCase("Some", "T")] [UnionCase("None")] -partial class Optional +partial class Option { } @@ -151,12 +155,14 @@ Note that generic types in general *must be fully qualified names unless you hav ### The `Match` function -`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Optional` type defined in the last section to equivalent F\# code. +**If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. In most cases using built-in C# pattern matching will be easier than using the `Match` function. See [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching)** + +`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Option` type defined in the last section to equivalent F\# code. ```csharp -// Here myOptionalValue is an Optional +// Here myOptionValue is an Option // The "None" handler can come before the "Some" handler as long as they're both named -var result = myOptionalValue.Match( +var result = myOptionValue.Match( None: () => "", Some: x => x); ``` @@ -164,7 +170,7 @@ var result = myOptionalValue.Match( Corresponding F\# code would look like: ```fsharp -let result = match myOptionalValue with +let result = match myOptionValue with | None -> "" | Some x -> x ``` @@ -174,7 +180,7 @@ Handling each case is not required, but a warning will be emitted by the `SumSha If you only want to handle some subset of cases, you can provide a default handler to prevent a warning from being emitted. ```csharp -var result = myOptionalValue.Match( +var result = myOptionValue.Match( Some: x => x, _: () => ""); ``` @@ -182,18 +188,60 @@ var result = myOptionalValue.Match( Again, the corresponding F\# code would look like: ```fsharp -let result = match myOptionalValue with +let result = match myOptionValue with | Some x -> x | _ -> "" ``` The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match` that is already exhaustive. It will also emit a warning if any case handlers are specified by position rather than name. Specifying by name is preferred because it makes the code clearer and prevents bugs/compilation errors if the case ordering changes. +### .NET 11 union types and pattern matching + +If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. All `SumSharp` unions implement [the non-boxing access pattern](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#non-boxing-access-pattern) and [union member providers](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#union-member-providers). + +Because C# union types do not support case names, `SumSharp` generates wrapper structs for each case in the union and places them as the same namespace/nested type level as the union itself. Empty cases get empty `partial` structs. This means that two `SumSharp` unions that share the same namespace/type heirarchy **cannot share identical non-empty case names**. These structs are used when pattern matching using built-in C# syntax such as `switch` or `is`. An example using the `Option` type that was defined above: + +```csharp +var x = Option.Some(4); + +var value = x switch +{ + Some(var i) => i, + None => 0, +}; + +// prints "value is 4" +Console.WriteLine($"value is {value}"); + +// prints "x is 4" +if (x is Some(4)) +{ + Console.WriteLine("x is 4"); +} +else if (x is None) +{ + Console.WriteLine("x is none"); +} + +``` + +#### Type union implementation details + +* `SumSharp` unions are *never null*. A non-null `SumSharp` union will never match with the `null` pattern, even if the underlying data it stores is null. +* The `IUnionMembers.Value` property is never null and will always return a boxed instance of one of the case structs. +* The `IUnionMembers.HasValue` property always returns true. +* The various `TryGetValue` overloads will wrap the underlying data in one of the case structs. +* `SumSharp` unions implement their corresponding `IUnionMembers` interface explicitly. This means that the `Value` and `HasValue` properties and the `TryGetValue` methods cannot be used unless you explicitly cast it to an `IUnionMembers`. In general you should not need to use any of these: they exist to satisfy the compiler's requirements for custom union types. + --- ## Motivation -C\# unfortunately does not offer discriminated unions as a language feature. Although [a proposal](https://github.com/dotnet/csharplang/blob/18a527bcc1f0bdaf542d8b9a189c50068615b439/proposals/TypeUnions.md) has existed for a while, this feature doesn't seem to be coming in the near future. +### `SumSharp` vs .NET 11 union types + +The union types introduced by .NET 11 are not true disrciminated unions because they lack the ability to define case names, thus not allowing for multiple cases of the same type. They also always box value types by default, which is unnecessary and often undesireable. They do, however, provide highly convenient pattern matching syntax using C\#'s built-in pattern matching operations such as `switch` and `is`. + +As mentioned in the quick start guide, `SumSharp` unions satisfy the requirements for .NET 11 union types. Wrapper structs are defined for each case, allowing for pattern matching behavior that is similar to languages with first class DUs such as F\#. Thus, `SumSharp` works synergistically with C\#'s unions types. You don't need to choose between the two: using `SumSharp` gives you the best of both. ### What about `OneOf`? diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index fc79579..9efc1ee 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -16,6 +17,8 @@ internal class SymbolHandler private const string IL2026SupressAttribute = "[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"IL2026:RequiresUnreferencedCode\", Justification = \"It is the library consumer's responsibility to ensure the required types are preserved.\")]"; private const string IL3050SupressAttribute = "[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"AOT\", \"IL3050:AotAnalysisWarning\", Justification = \"It is the library consumer's responsibility to ensure the required types are preserved.\")]"; + private static readonly string GeneratedCodeAttribute = $"[System.CodeDom.Compiler.GeneratedCode(\"SumSharp\", \"{Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion}\")]"; + public abstract class TypeInfo { public abstract string Name { get; } @@ -255,7 +258,10 @@ public CaseData(int index, string name, TypeInfo? typeInfo, bool storeAsObject, public CaseData[] UniqueCases { get; } - public TypeInfo[] DistinctTypes { get; } + public Dictionary Net11StructNameMap { get; } + + // Cases grouped by type + public IGrouping[] CaseGroups { get; } public INamedTypeSymbol[] ContainingTypes; @@ -419,36 +425,86 @@ public SymbolHandler( }) .ToArray(); - var typeMap = new Dictionary(); - - foreach (var caseData in Cases) - { - if (caseData.TypeInfo is null) - { - continue; - } - - typeMap[caseData.TypeInfo.Name] = caseData.TypeInfo; - } - - DistinctTypes = [..typeMap.Values]; + CaseGroups = + [..Cases + .Where(caseData => caseData.TypeInfo is not null) + .GroupBy(caseData => + caseData.TypeInfo!.IsTupleType ? + $"({string.Join(", ", caseData.TypeInfo.TupleTypeArgs)})" : // Removes custom field names + caseData.TypeInfo.Name)]; - if (storageStrategy == 0 && DistinctTypes.Length == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) + if (storageStrategy == 0 && CaseGroups.Length == 1 && !Cases.Any(caseData => caseData.StorageMode == 1)) { Cases = [.. Cases.Select(caseData => new CaseData(caseData.Index, caseData.Name, caseData.TypeInfo, false, caseData.StorageMode, FullUnmanagedStorageTypeName))]; } UniqueCases = - Cases - .Where(caseData => caseData.TypeInfo is not null) - .GroupBy(caseData => - caseData.TypeInfo!.IsTupleType ? - $"({string.Join(", ", caseData.TypeInfo.TupleTypeArgs)})" : // Removes custom field names - caseData.TypeInfo.Name) + CaseGroups .Where(group => group.Count() == 1) .SelectMany(group => group) .ToArray(); + Net11StructNameMap = Cases.ToDictionary(caseData => caseData, caseData => + { + if (caseData.TypeInfo is null || !caseData.TypeInfo.IsGeneric) + { + return (caseData.Name, ""); + } + else + { + var parsedTypeArguments = TypeNameParser.ExtractLeafTypes(caseData.TypeInfo.Name); + + var caseStructTypeArguments = TypeArguments.Intersect(parsedTypeArguments).ToArray(); + + var caseStructTypeConstraints = + caseStructTypeArguments + .Select(typeArg => + { + var typeSymbol = (ITypeParameterSymbol)allGenericTypeArguments.Single(symbol => symbol.Name == typeArg); + + var constraints = new List(); + + if (typeSymbol.HasNotNullConstraint) + { + constraints.Add("notnull"); + } + else if (typeSymbol.HasReferenceTypeConstraint) + { + constraints.Add("class"); + } + + if (typeSymbol.HasUnmanagedTypeConstraint) + { + constraints.Add("unmanaged"); + } + else if (typeSymbol.HasValueTypeConstraint) + { + constraints.Add("struct"); + } + + if (typeSymbol.HasConstructorConstraint) + { + constraints.Add("new()"); + } + + if (constraints.Count == 0) + { + return ""; + } + + return $"where {typeArg} : {string.Join(", ", constraints)}"; + }) + .Where(contraints => contraints.Length > 0) + .ToArray(); + + var nameWithTypeArgs = $"{caseData.Name}{(caseStructTypeArguments.Length == 0 ? "" : $"<{string.Join(", ", caseStructTypeArguments)}>")}"; + + var constraints = string.Join(" ", caseStructTypeConstraints); + + return (nameWithTypeArgs, constraints); + } + }); + var enableJsonSerializationData = symbol! .GetAttributes() @@ -621,6 +677,8 @@ public string Emit() EmitCaseConstructors(); + EmitNativeUnion(); + EmitAs(); EmitIs(); @@ -737,23 +795,63 @@ private void EmitFieldsAndConstructor() fieldNameTypeMap[caseData.FieldType!] = caseData.FieldName!; } - List interfaces = []; + string interfaces = ": "; if (!DisableValueEquality) { - interfaces.Add($"System.IEquatable<{Name}>"); + interfaces += $@" + System.IEquatable<{Name}>"; } if (IsDisposable) { - interfaces.Add("System.IDisposable"); + interfaces += @", + System.IDisposable"; } if (IsAsyncDisposable) { - interfaces.Add("System.IAsyncDisposable"); + interfaces += @", + System.IAsyncDisposable"; + } + + if (interfaces == ": ") + { + interfaces = $@" +#if NET11_0_OR_GREATER + : {Name}.IUnionMembers +#endif"; + } + else + { + interfaces += $@" +#if NET11_0_OR_GREATER + , {Name}.IUnionMembers +#endif"; + } + + Builder.AppendLine($@" +#if NET11_0_OR_GREATER"); + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.AppendLine($@" + ///Used to implement .NET 11 union requirements. Use this type when pattern matching using C#'s built-in switch statement + public readonly partial record struct {caseData.Name};"); + } + else + { + Builder.AppendLine($@" + ///Used to implement .NET 11 union requirements. Use this type when pattern matching using C#'s built-in switch statement + {GeneratedCodeAttribute} + public readonly record struct {Net11StructNameMap[caseData].NameWithTypeArgs}({caseData.TypeInfo.Name} Value) {Net11StructNameMap[caseData].Constraints};"); + } } Builder.Append($@" -{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name}{(interfaces.Count == 0 ? "" : $" : {string.Join(", ", interfaces)}")} +[System.Runtime.CompilerServices.Union] +#endif +{GeneratedCodeAttribute} +{Accessibility} partial {GetDeclarationKind(IsStruct, IsRecord)} {Name} {interfaces} {{"); foreach (var field in fieldNameTypeMap) @@ -800,8 +898,7 @@ public void EmitStaticConstructor() { var unmanagedTypes = Cases.Where(caseData => caseData.UseUnmanagedStorage) - .Select(caseData => caseData.TypeInfo!.Name) - .ToImmutableHashSet(); + .Select(caseData => caseData.TypeInfo!.Name); foreach (var type in unmanagedTypes) { @@ -827,7 +924,7 @@ static void CheckUnmanagedStorage() where TUnmanaged__ : unmanaged var _ = new StandardJsonConverter();"); } - Builder.AppendLine(@" + Builder.AppendLine(@" }"); } @@ -838,6 +935,7 @@ public void EmitUnmanagedStorageSize() public static int UnmanagedStorageSize => _unmanagedStorageSize;"); } + public void EmitEquals() { Builder.Append($@" @@ -845,6 +943,7 @@ public void EmitEquals() public bool Equals({Name}{NullableIfRef} other) {{ {(IsStruct ? "" : "if (other is null) return false;")} + {(IsStruct ? "" : "if (ReferenceEquals(this, other)) return true;")} if (Index != other.Index) return false; return Index switch @@ -917,89 +1016,93 @@ public override int GetHashCode() #if NET9_0_OR_GREATER"); } - foreach (var type in DistinctTypes) + foreach (var caseGroup in CaseGroups) { + var type = caseGroup.First().TypeInfo!; + Builder.Append($@" ///Compares a {XMLEscapedName} with a for equality using on the underlying value public static bool operator==({Name} left, {type.Name} right) {{ - return left.Index switch + switch (left.Index) {{"); foreach (var caseData in Cases) { if (caseData.TypeInfo is null) { - Builder.Append($@" - {caseData.Index} => false,"); - continue; } - - switch (type.IsGeneric, caseData.TypeInfo.IsGeneric) + + if (caseData.TypeInfo.IsGeneric || type.IsGeneric) { - case (false, false): - - if (caseData.TypeInfo.IsAlwaysValueType) - { - Builder.Append($@" - {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); - } - else - { - Builder.Append($@" - {caseData.Index} => {(caseData.TypeInfo.Name == type.Name ? $"left.As{caseData.Name}Unsafe is null ? right is null : left.As{caseData.Name}Unsafe.Equals(right)" : "false")},"); - } - - break; - case (false, true): + if (caseData.TypeInfo.Name == type.Name) + { + Builder.Append($@" + case {caseData.Index}: return typeof({caseData.TypeInfo.Name}).IsValueType ? left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right) : (ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right));"); + } + else if (caseData.TypeInfo.IsAlwaysValueType || type.IsAlwaysValueType) + { + Builder.Append($@" + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - if (type.IsAlwaysValueType) - { - Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right),"); - } - else - { - Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && (ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right)),"); - } + return System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue){NullForgiving}.Equals(right); + }} + break;"); + } + else if (caseData.TypeInfo.IsAlwaysRefType || type.IsAlwaysRefType) + { + Builder.Append($@" + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - break; - case (true, false): + var castedLeftValue = System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue); + return ReferenceEquals(null, castedLeftValue) ? ReferenceEquals(null, right) : castedLeftValue.Equals(right); + }} + break;"); + } + else + { Builder.Append($@" - {caseData.Index} => typeof({caseData.TypeInfo.Name}) == typeof({type.Name}) && left.As{caseData.Name}Unsafe.Equals(right),"); - - break; + case {caseData.Index}: + if (typeof({caseData.TypeInfo.Name}) == typeof({type.Name})) + {{ + var leftValue = left.As{caseData.Name}Unsafe; - case (true, true): - { - var expression = new List(); - - if (caseData.TypeInfo.Name != type.Name) - { - expression.Add($"typeof({caseData.TypeInfo.Name}) == typeof({type.Name})"); - } - if (caseData.TypeInfo.IsAlwaysValueType || type.IsAlwaysValueType) - { - expression.Add($"left.As{caseData.Name}Unsafe{NullForgiving}.Equals(right)"); - } - else - { - expression.Add($"(ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right))"); - } - - Builder.Append($@" - {caseData.Index} => {string.Join(" && ", expression)},"); + var castedLeftValue = System.Runtime.CompilerServices.Unsafe.As<{caseData.TypeInfo.Name}, {type.Name}>(ref leftValue); - } - break; + return typeof({caseData.TypeInfo.Name}).IsValueType ? castedLeftValue{NullForgiving}.Equals(right) : (ReferenceEquals(null, castedLeftValue) ? ReferenceEquals(null, right) : castedLeftValue.Equals(right)); + }} + break;"); + } + } + else if (caseData.TypeInfo.Name == type.Name) + { + if (caseData.TypeInfo.IsAlwaysValueType) + { + Builder.Append($@" + case {caseData.Index}: return left.As{caseData.Name}Unsafe.Equals(right);"); + } + else + { + Builder.Append($@" + case {caseData.Index}: return ReferenceEquals(null, left.As{caseData.Name}Unsafe) ? ReferenceEquals(null, right) : left.As{caseData.Name}Unsafe.Equals(right);"); + } } } Builder.AppendLine($@" - }}; + default: break; + }} + + return false; }} + ///Compares a with a {XMLEscapedName} for equality using on the underlying value public static bool operator==({type.Name} left, {Name} right) => right == left; @@ -1092,6 +1195,97 @@ private void EmitCaseConstructors() } } + public void EmitNativeUnion() + { + Builder.AppendLine($@" +#if NET11_0_OR_GREATER + public interface IUnionMembers + {{ + ///Returns the underlying value of the union as an {Nullable}. Value types will be boxed + public object Value {{ get; }} + + ///Always returns true. SumSharp unions are always considered non-null, even if the active case is empty + public bool HasValue {{ get; }}"); + + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.AppendLine($@" + ///Returns the singleton . The input value is ignored. This function exists to satisfy the compiler's requirements for .NET 11 union types + public static {Name} Create({Net11StructNameMap[caseData].NameWithTypeArgs} _) => {Name}.{caseData.Name};"); + + } + else + { + Builder.AppendLine($@" + ///Creates a that holds a value of type by invoking the case constructor with .Value + ///This function exists to satisfy the compiler's requirements for .NET 11 union types + public static {Name} Create({Net11StructNameMap[caseData].NameWithTypeArgs} value) => {Name}.{caseData.Name}(value.Value);"); + } + + Builder.AppendLine($@" + ///Attempts to get a value of type from the union. Returns true if the union holds a {caseData.Name}. + ///Returns false otherwise. + ///An out parameter that will be set to the underlying value, if present. + public bool TryGetValue(out {Net11StructNameMap[caseData].NameWithTypeArgs} value);"); + } + + Builder.AppendLine($@" + }}"); + + Builder.Append($@" + object IUnionMembers.Value + {{ + get + {{ + return Index switch + {{"); + + foreach (var caseData in Cases) + { + if (caseData.TypeInfo is null) + { + Builder.Append($@" + {caseData.Index} => new {Net11StructNameMap[caseData].NameWithTypeArgs}(),"); + } + else + { + Builder.Append($@" + {caseData.Index} => new {Net11StructNameMap[caseData].NameWithTypeArgs}(As{caseData.Name}Unsafe),"); + } + } + + Builder.AppendLine($@" + }}; + }} + }} + + bool IUnionMembers.HasValue => true;"); + + foreach (var caseData in Cases) + { + Builder.AppendLine($@" + bool IUnionMembers.TryGetValue(out {Net11StructNameMap[caseData].NameWithTypeArgs} value) + {{ + value = default; + + if (Index != {caseData.Index}) + {{ + return false; + }} + + {(caseData.TypeInfo is null ? "" : $"value = new(As{caseData.Name}Unsafe);")} + + return true; + }}"); + + } + + Builder.AppendLine(@" +#endif"); + } + public void EmitAs() { foreach (var caseData in Cases) @@ -1175,6 +1369,7 @@ public void EmitAs() public ValueTask<{caseData.TypeInfo.Name}> As{caseData.Name}Or(System.Func> defaultValueFactory) => Index == {caseData.Index} ? ValueTask.FromResult(As{caseData.Name}Unsafe) : new ValueTask<{caseData.TypeInfo.Name}>(defaultValueFactory());"); } } + public void EmitIs() { foreach (var caseData in Cases) @@ -1615,6 +1810,7 @@ private void EmitStandardJsonConverter() { Builder.Append($@" ///System.Text.Json converter capable of serializing and deserializing a {XMLEscapedName} + {GeneratedCodeAttribute} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverter<{Name}> {{ {(UsingAOTCompilation ? IL2026SupressAttribute : "")} @@ -1729,6 +1925,7 @@ private void EmitNewtonsoftJsonConverter() { Builder.Append($@" ///Newtonsoft converter capable of serializing and deserializing a {XMLEscapedName} + {GeneratedCodeAttribute} public partial class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter<{Name}> {{ public override {Name}{NullableIfRef} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, {Name}{NullableIfRef} existingValue, bool hasExistingValue, Newtonsoft.Json.JsonSerializer serializer) @@ -1844,6 +2041,7 @@ private void EmitEndClassDeclaration() private void EmitStaticClass() { Builder.Append($@" +{GeneratedCodeAttribute} {Accessibility} static partial class {NameWithoutTypeArguments} {{"); } @@ -1855,6 +2053,7 @@ private void EmitStandardJsonConverterFactory() Builder.Append($@" ///System.Text.Json converter capable of serializing and deserializing any {NameWithoutTypeArguments} {(UsingAOTCompilation ? IL3050SupressAttribute : "")} + {GeneratedCodeAttribute} public partial class StandardJsonConverter : System.Text.Json.Serialization.JsonConverterFactory {{ public override bool CanConvert(System.Type typeToConvert) @@ -1877,45 +2076,45 @@ private void EmitGenericNewtonsoftJsonConverter() var genericTypeDefinition = $"{NameWithoutTypeArguments}<{new string(',', TypeArguments.Length - 1)}>"; Builder.AppendLine($@" -///Newtonsoft converter capable of serializing and deserializing any {NameWithoutTypeArguments} -public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter -{{ - static readonly System.Collections.Concurrent.ConcurrentDictionary _converters = new(); - - private static Newtonsoft.Json.JsonConverter GetConverter(System.Type objectType) + ///Newtonsoft converter capable of serializing and deserializing any {NameWithoutTypeArguments} + {GeneratedCodeAttribute} + public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter {{ - return _converters.GetOrAdd(objectType, static objectType => + static readonly System.Collections.Concurrent.ConcurrentDictionary _converters = new(); + + private static Newtonsoft.Json.JsonConverter GetConverter(System.Type objectType) {{ - var converterType = typeof({genericTypeDefinition}.NewtonsoftJsonConverter).MakeGenericType(objectType.GetGenericArguments()); + return _converters.GetOrAdd(objectType, static objectType => + {{ + var converterType = typeof({genericTypeDefinition}.NewtonsoftJsonConverter).MakeGenericType(objectType.GetGenericArguments()); - return (Newtonsoft.Json.JsonConverter)System.Activator.CreateInstance(converterType); - }}); - }} + return (Newtonsoft.Json.JsonConverter)System.Activator.CreateInstance(converterType); + }}); + }} - public override bool CanConvert(System.Type objectType) - {{ - return objectType.IsGenericType && - objectType.GetGenericTypeDefinition() == typeof({genericTypeDefinition}); - }} + public override bool CanConvert(System.Type objectType) + {{ + return objectType.IsGenericType && + objectType.GetGenericTypeDefinition() == typeof({genericTypeDefinition}); + }} - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object{Nullable} value, Newtonsoft.Json.JsonSerializer serializer) - {{ - if (value is null) + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object{Nullable} value, Newtonsoft.Json.JsonSerializer serializer) {{ - writer.WriteNull(); + if (value is null) + {{ + writer.WriteNull(); - return; - }} + return; + }} - GetConverter(value.GetType()).WriteJson(writer, value, serializer); - }} + GetConverter(value.GetType()).WriteJson(writer, value, serializer); + }} - public override object{Nullable} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, object{Nullable} existingValue, Newtonsoft.Json.JsonSerializer serializer) - {{ - return GetConverter(objectType).ReadJson(reader, objectType, existingValue, serializer); - }} -}} -"); + public override object{Nullable} ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, object{Nullable} existingValue, Newtonsoft.Json.JsonSerializer serializer) + {{ + return GetConverter(objectType).ReadJson(reader, objectType, existingValue, serializer); + }} + }}"); } private void EmitEndStaticClass() diff --git a/SumSharp.Generator/TypeNameParser.cs b/SumSharp.Generator/TypeNameParser.cs new file mode 100644 index 0000000..9fbc9ba --- /dev/null +++ b/SumSharp.Generator/TypeNameParser.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SumSharp.Generator; +public static class TypeNameParser +{ + public static List ExtractLeafTypes(string typeName) + { + var parser = new Parser(typeName); + var result = new List(); + + parser.ParseType(result); + + return result; + } + + private sealed class Parser(string typeName) + { + private int _pos = 0; + + public void ParseType(List output) + { + SkipWhitespace(); + + if (Peek() == '(') + { + ParseTuple(output); + return; + } + + string identifier = ParseIdentifier(); + + SkipWhitespace(); + + // Generic? + if (Peek() == '<') + { + Consume('<'); + + while (true) + { + ParseType(output); + + SkipWhitespace(); + + if (Peek() == ',') + { + Consume(','); + continue; + } + + Consume('>'); + break; + } + } + else + { + output.Add(identifier); + } + + // Ignore array suffixes + while (true) + { + SkipWhitespace(); + + if (Peek() != '[') + break; + + Consume('['); + + while (Peek() != ']') + _pos++; + + Consume(']'); + } + + // Optional nullable suffix + if (Peek() == '?') + Consume('?'); + } + + private void ParseTuple(List output) + { + Consume('('); + + while (true) + { + ParseType(output); + + SkipWhitespace(); + + // Skip tuple field name if present + if (char.IsLetter(Peek()) || Peek() == '_') + { + ParseIdentifier(); + } + + SkipWhitespace(); + + if (Peek() == ',') + { + Consume(','); + continue; + } + + Consume(')'); + break; + } + } + + private string ParseIdentifier() + { + SkipWhitespace(); + + int start = _pos; + + while (_pos < typeName.Length) + { + char c = typeName[_pos]; + + if (char.IsLetterOrDigit(c) || c == '_' || c == '.') + { + _pos++; + } + else + { + break; + } + } + + return typeName.Substring(start, _pos - start); + } + + private void SkipWhitespace() + { + while (_pos < typeName.Length && char.IsWhiteSpace(typeName[_pos])) + _pos++; + } + + private char Peek() + { + return _pos < typeName.Length ? typeName[_pos] : '\0'; + } + + private void Consume(char c) + { + if (Peek() != c) + throw new FormatException($"Expected '{c}'."); + + _pos++; + } + } +} \ No newline at end of file diff --git a/SumSharp.sln b/SumSharp.sln index aed0033..a592b21 100644 --- a/SumSharp.sln +++ b/SumSharp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35806.99 +# Visual Studio Version 18 +VisualStudioVersion = 18.8.12023.21 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp", "SumSharp\SumSharp.csproj", "{F757C23E-EF40-420D-AE51-9A0491392968}" EndProject @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AOT", "Tests.AOT\Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SumSharp.Analyzer", "SumSharp.Analyzer\SumSharp.Analyzer.csproj", "{D50E46B6-6A70-4A3C-A89F-348A1566825F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Net11", "Tests.Net11\Tests.Net11.csproj", "{76BD98D1-AA76-4FED-8802-7EB256A1A187}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Debug|Any CPU.Build.0 = Debug|Any CPU {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D50E46B6-6A70-4A3C-A89F-348A1566825F}.Release|Any CPU.Build.0 = Release|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76BD98D1-AA76-4FED-8802-7EB256A1A187}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tests.Net11/Tests.Net11.csproj b/Tests.Net11/Tests.Net11.csproj new file mode 100644 index 0000000..d330533 --- /dev/null +++ b/Tests.Net11/Tests.Net11.csproj @@ -0,0 +1,28 @@ + + + + net11.0 + preview + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests.Net11/Union.cs b/Tests.Net11/Union.cs new file mode 100644 index 0000000..6297728 --- /dev/null +++ b/Tests.Net11/Union.cs @@ -0,0 +1,204 @@ +using SumSharp; + +namespace Tests.Net11; + +public partial class Union +{ + [UnionCase("Int", typeof(int))] + [UnionCase("String", typeof(string))] + [UnionCase("Other", "T")] + + partial class IntOrStringOrOther + { + + } + + [UnionCase("Some", "T")] + [UnionCase("None")] + partial class Option + { + + } + + [UnionCase("IntArray", typeof(int[]))] + [UnionCase("EmptyCase1")] + [UnionCase("EmptyCase2")] + partial class EmptyCases1 + { + + } + + [UnionCase("EmptyCase0")] + [UnionCase("EmptyCase1")] + [UnionCase("FloatArray", typeof(float[]))] + partial class EmptyCases2 + { + + } + + public partial class OuterGeneric + { + public partial class InnerGeneric + where U : class + where V : unmanaged + { + [UnionCase("Case0", "T")] + [UnionCase("Case1", "U[]")] + [UnionCase("Case2", "Dictionary")] + [UnionCase("Case3", "(W[] WArray, bool Boolean)")] + [UnionCase("Case4", "X")] + public partial struct ComplexGeneric + where W : class, new() + where X : struct + { + + } + } + } + + [Fact] + public void Value() + { + Assert.Equal(new Int(5), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Int(5)).Value); + Assert.Equal(new String("abc"), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.String("abc")).Value); + Assert.Equal(new Other(true), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(true)).Value); + Assert.Equal(new Other(4), ((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(4)).Value); + Assert.Equal(new None(), ((Option.IUnionMembers)Option.None).Value); + Assert.Equal(new EmptyCase1(), ((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase1).Value); + Assert.Equal(new EmptyCase2(), ((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase2).Value); + Assert.Equal(new EmptyCase0(), ((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase0).Value); + Assert.Equal(new EmptyCase1(), ((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase1).Value); + } + + [Fact] + public void HasValue() + { + Assert.True(((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Int(5)).HasValue); + Assert.True(((IntOrStringOrOther.IUnionMembers)IntOrStringOrOther.Other(null)).HasValue); + + Assert.True(((Option.IUnionMembers)Option.Some(1)).HasValue); + Assert.True(((Option.IUnionMembers)Option.None).HasValue); + + Assert.True(((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase1).HasValue); + Assert.True(((EmptyCases1.IUnionMembers)EmptyCases1.EmptyCase2).HasValue); + Assert.True(((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase0).HasValue); + Assert.True(((EmptyCases2.IUnionMembers)EmptyCases2.EmptyCase1).HasValue); + } + + + [Fact] + public void Switch() + { + Assert.True(IntOrStringOrOther.Int(5) switch + { + Int(var i) => i == 5, + String(var s) => false, + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.String("abc") switch + { + Int(var i) => false, + String(var s) => s == "abc", + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.String(null!) switch + { + Int(var i) => false, + String(null) => true, + String => false, + Other(var b) => false, + }); + + Assert.True(IntOrStringOrOther.Other(true) switch + { + Int(var i) => false, + String(var s) => false, + Other(var b) => b, + }); + + Assert.True(IntOrStringOrOther.Other(4) switch + { + Int(var i) => false, + String(var s) => false, + Other(var i) => i == 4, + }); + + Assert.True(IntOrStringOrOther.Other(null) switch + { + Int(var i) => false, + String(var s) => false, + Other(var i) => !i.HasValue, + }); + + Assert.True(IntOrStringOrOther.Other(null!) switch + { + Int(var i) => false, + String(var s) => false, + Other(var f) => f is null, + }); + + Assert.True(Option.Some("abc") switch + { + Some("abc") => true, + Some => false, + None => false, + }); + + Assert.True(Option.None switch + { + Some => false, + None => true, + }); + + Assert.True(EmptyCases1.IntArray([0]) switch + { + IntArray([0]) => true, + IntArray => false, + EmptyCase1 => false, + EmptyCase2 => false, + }); + + Assert.True(EmptyCases2.EmptyCase1 switch + { + EmptyCase0 => false, + EmptyCase1 => true, + FloatArray => false, + }); + } + + [Fact] + public void ComplexGeneric() + { + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case0("abc") switch + { + OuterGeneric.InnerGeneric.Case0("abc") => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case1([[1.0f], [2.0f, 3.0f]]) switch + { + OuterGeneric.InnerGeneric.Case1([[1.0f], [2.0f, 3.0f]]) => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case2(new() { [4.0] = ([1, 2], [3, 4]) }) switch + { + OuterGeneric.InnerGeneric.Case2, double>(var dict) => dict[4.0] is ([1, 2], [3, 4]), + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case3(([[1], [2, 3]], false)) switch + { + OuterGeneric.InnerGeneric.Case3>(([[1], [2, 3]], false)) => true, + _ => false + }); + + Assert.True(OuterGeneric.InnerGeneric.ComplexGeneric, double>.Case4(3.0) switch + { + OuterGeneric.InnerGeneric.Case4(3.0) => true, + _ => false + }); + } +} From cd9bf73603f00b9ec6db40c8763987988885a626 Mon Sep 17 00:00:00 2001 From: Christian Daley Date: Sun, 9 Aug 2026 11:51:17 -0700 Subject: [PATCH 19/19] fix accessibility of case structs (#32) * fix accessibility of case structs * small change --- SumSharp.Generator/SymbolHandler.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/SumSharp.Generator/SymbolHandler.cs b/SumSharp.Generator/SymbolHandler.cs index 9efc1ee..27f1346 100644 --- a/SumSharp.Generator/SymbolHandler.cs +++ b/SumSharp.Generator/SymbolHandler.cs @@ -452,9 +452,7 @@ public SymbolHandler( } else { - var parsedTypeArguments = TypeNameParser.ExtractLeafTypes(caseData.TypeInfo.Name); - - var caseStructTypeArguments = TypeArguments.Intersect(parsedTypeArguments).ToArray(); + var caseStructTypeArguments = TypeArguments.Intersect(TypeNameParser.ExtractLeafTypes(caseData.TypeInfo.Name)).ToArray(); var caseStructTypeConstraints = caseStructTypeArguments @@ -836,14 +834,14 @@ private void EmitFieldsAndConstructor() { Builder.AppendLine($@" ///Used to implement .NET 11 union requirements. Use this type when pattern matching using C#'s built-in switch statement - public readonly partial record struct {caseData.Name};"); + {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} - public readonly record struct {Net11StructNameMap[caseData].NameWithTypeArgs}({caseData.TypeInfo.Name} Value) {Net11StructNameMap[caseData].Constraints};"); + {Accessibility} readonly record struct {Net11StructNameMap[caseData].NameWithTypeArgs}({caseData.TypeInfo.Name} Value) {Net11StructNameMap[caseData].Constraints};"); } }