Skip to content

feat(discriminated-union): closed-DU source generator - #115

Merged
1 commit merged into
mainfrom
feat/discriminated-union-generator
May 7, 2026
Merged

feat(discriminated-union): closed-DU source generator#115
1 commit merged into
mainfrom
feat/discriminated-union-generator

Conversation

@ANcpLua

@ANcpLua ANcpLua commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

New analyzer package ANcpLua.Analyzers.DiscriminatedUnion, sibling to ExtensibleEnumMirror and AotReflection. Turns a `[DiscriminatedUnion]`-marked partial record root + nested partial record cases into a closed F#-style discriminated union — private base ctor that locks inheritance to the nested cases, sealed case records, and exhaustive `Match` / `Switch` dispatchers that the compiler enforces.

What gets generated

```csharp
[DiscriminatedUnion]
public partial record Msg
{
public partial record AddPoint(double X, double Y);
public partial record Undo;
public partial record Redo;
}
```

— gets a separate partial declaration that injects:

  • `abstract` modifier on `Msg`
  • `private Msg() { }` (only nested types can call it → closed hierarchy)
  • `sealed partial record AddPoint : Msg`, etc.
  • `public abstract TResult Match(Func<AddPoint, TResult>, Func<Undo, TResult>, Func<Redo, TResult>)` plus per-case override that dispatches to the correct argument
  • `Switch` with the same shape for void operations

Generic roots like `Result` flow type parameters through to the case base list.

Diagnostics

ID Severity Reason
AL0300 Error Union root must be `partial record`
AL0301 Error Union root must declare at least one nested partial record case
AL0302 Error A nested member of the union root must be a partial record
AL0303 Error Union root must not declare primary-constructor parameters (would conflict with the generated parameterless base)

Test plan

  • Happy path: emits private ctor, sealed cases, Match/Switch dispatchers
  • Output compilation has no errors and `Match` is callable from consumer code
  • Closed hierarchy: external `record EvilMsg : Msg` fails to compile
  • AL0300, AL0301, AL0302, AL0303 reported on bad input
  • Generic union root `Result` flows `` to case base list

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added a discriminated union analyzer with four new diagnostic rules (AL0300–AL0303) to enforce proper union structure and constraints
    • Introduced a source generator that automatically creates match and switch APIs for discriminated union patterns, enabling exhaustive pattern matching

Adds ANcpLua.Analyzers.DiscriminatedUnion as a sibling to ExtensibleEnumMirror
and AotReflection. The generator turns a [DiscriminatedUnion]-marked partial
record root and its nested partial record cases into a closed F#-style
discriminated union:

- private parameterless base ctor on the root → only nested cases can derive
- sealed case records generated as partial declarations of the user's records
- exhaustive Match<TResult>(Func<Case1,TResult>, …) and Switch(Action<Case1>, …)
  dispatchers — adding a case without updating Match is a Build-error, not a
  runtime default arm

Userland:

    [DiscriminatedUnion]
    public partial record Msg
    {
        public partial record AddPoint(double X, double Y);
        public partial record Undo;
        public partial record Redo;
    }

Generator emits a separate partial declaration that injects `abstract`,
`private Msg() { }`, the sealed `: Msg` cases, and the dispatchers.

Diagnostics: AL0300 (root must be partial record), AL0301 (no cases),
AL0302 (case must be nested partial record), AL0303 (root must not have a
primary constructor — would conflict with the generated parameterless base).

Generic union roots are supported (`Result<T> { Ok(T Value); Err(string Reason); }`)
— TypeParameterList flows through into the case base list.

Tests: 8 cases covering the happy path, output compilation, closed-hierarchy
enforcement, all four diagnostics, and the generic-root scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 09:24
@ghost
ghost enabled auto-merge (squash) May 7, 2026 09:24
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 412d80c1-8d99-4df2-a605-1c089ecdbb48

📥 Commits

Reviewing files that changed from the base of the PR and between f73b967 and ca88da3.

📒 Files selected for processing (12)
  • ANcpLua.Roslyn.Utilities.slnx
  • src/ANcpLua.DiscriminatedUnion/ANcpLua.Analyzers.DiscriminatedUnion.csproj
  • src/ANcpLua.DiscriminatedUnion/AnalyzerReleases.Shipped.md
  • src/ANcpLua.DiscriminatedUnion/AnalyzerReleases.Unshipped.md
  • src/ANcpLua.DiscriminatedUnion/DiagnosticDescriptors.cs
  • src/ANcpLua.DiscriminatedUnion/DiscriminatedUnionGenerator.cs
  • src/ANcpLua.DiscriminatedUnion/Extraction/UnionExtractor.cs
  • src/ANcpLua.DiscriminatedUnion/Generation/UnionOutputGenerator.cs
  • src/ANcpLua.DiscriminatedUnion/Models/UnionCase.cs
  • src/ANcpLua.DiscriminatedUnion/Models/UnionModel.cs
  • tests/ANcpLua.Roslyn.Utilities.DiscriminatedUnion.Tests/ANcpLua.Roslyn.Utilities.DiscriminatedUnion.Tests.csproj
  • tests/ANcpLua.Roslyn.Utilities.DiscriminatedUnion.Tests/DiscriminatedUnionGeneratorTests.cs

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting


📝 Walkthrough

Walkthrough

This PR introduces a discriminated-union source generator for Roslyn-based code generation. It adds a new analyzer project (ANcpLua.Analyzers.DiscriminatedUnion) targeting netstandard2.0, configured for NuGet distribution as an analyzer package. The generator processes records decorated with [DiscriminatedUnion], extracts nested partial-record cases, and emits sealed-record overrides with exhaustive Match<TResult> and Switch dispatch APIs. Four validation rules (AL0300–AL0303) enforce structural requirements: partial root, presence of cases, partial-record case types, and prohibition of primary-constructor parameters. A comprehensive test suite validates code generation, diagnostics, and compilation correctness.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown

@coderabbitai autofix

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Autofix skipped. No unresolved CodeRabbit review comments with fix instructions found.

@ghost
ghost merged commit 8b2db76 into main May 7, 2026
13 of 15 checks passed
@ghost
ghost deleted the feat/discriminated-union-generator branch May 7, 2026 09:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca88da33a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +45 to +48
var caseSyntaxes = rootSyntax.Members
.OfType<RecordDeclarationSyntax>()
.Where(r => r.Kind() is SyntaxKind.RecordDeclaration)
.ToArray();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Collect union cases from all partial declarations

This extractor only reads rootSyntax.Members from the single declaration that carries [DiscriminatedUnion], so valid cases declared in another partial part of the same root are ignored. In that layout, the generator can emit AL0301 (“no cases”) or generate incomplete Match/Switch signatures that omit real cases, which breaks otherwise-correct partial union definitions split across files.

Useful? React with 👍 / 👎.

sb.AppendLine();
}

sb.AppendLine($"abstract partial record {model.RootName}{model.TypeParameterList}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit nested declaration chain for nested union roots

The generator always emits the root as a namespace-level type (abstract partial record {RootName}), but ForAttributeWithMetadataName can target nested records too. For a nested union root, this output no longer matches the original containing type chain, so generation produces the wrong type shape (or a conflicting new top-level type) instead of extending the annotated root.

Useful? React with 👍 / 👎.

Comment on lines +105 to +108
private static string GetHintName(UnionModel model) =>
string.IsNullOrEmpty(model.RootNamespace)
? $"{model.RootName}.DiscriminatedUnion.g.cs"
: $"{model.RootNamespace}.{model.RootName}.DiscriminatedUnion.g.cs";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make generated hint names unique across containing types

The hint name is based only on namespace + root name, so two annotated roots with the same name in the same namespace (for example under different containing types, or different arity) produce identical hint names. AddSource requires uniqueness per generator invocation, so this can throw and stop generation for one or both unions.

Useful? React with 👍 / 👎.

This pull request was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant