Skip to content

F# code generation for the pre-generated code model (#383) — Milestone 1#384

Merged
jeremydmiller merged 7 commits into
mainfrom
feat/383-fsharp-codegen
May 27, 2026
Merged

F# code generation for the pre-generated code model (#383) — Milestone 1#384
jeremydmiller merged 7 commits into
mainfrom
feat/383-fsharp-codegen

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

@jeremydmiller jeremydmiller commented May 27, 2026

Implements Milestone 1 of #383 and several follow-on increments: emit F# as an alternative to C# for the pre-generated (static) code model only (never in-memory Roslyn). The C# path is untouched — every new per-frame member is virtual with a default-throw NotSupportedException, so existing frames keep compiling and working.

Writer + seam (Phase 1)

  • CodeGeneration/FSharp/FSharpSourceWriter.cs : ISourceWriterBLOCK:/END/FinishBlock indent/dedent without braces (F# significant whitespace); line writing + backtick→" identical to the C# writer.
  • Frame.GenerateFSharpCode(...)virtual, default-throws naming the frame.
  • Variable.FSharpAssignmentUsage + Mutable flag (let vs let mutable).
  • Type.FSharpName() — F# primitive aliases (doublefloat, voidunit, …), arrays, closed generics; throws loudly on open generics / tuples / by-ref.

Outer emit layers (Phase 2)

  • GeneratedAssembly.GenerateFSharpCode (namespace + open + types at column 0)
  • GeneratedType.WriteFSharp (primary ctor, let _x = x fields, interface-member grouping, base-class inherit)
  • GeneratedMethod.WriteFSharpMethod (member signature; drives the same arranged frame chain)

Frames

  • Statement frames: CommentFrame, CodeFrame, ConstructorFrame, MethodCall, ReturnFrame. F# is expression-oriented, so ReturnFrame emits a bare trailing expression outside a task block and return x inside one.
  • Control-flow frames: CompositeFrame (parallel F# chaining + a generateFSharpCode hook), IfBlock (if cond then), IfElseNullGuardFrame (if isNull x then … else …) + its IfNullGuardFrame (if not (isNull x) then), and TryFinallyWrapperFrame (try … finally …).

Async-mode matrix & emit refinements

A task { } block is emitted iff AsyncMode == AsyncTask:

  • AsyncTasktask { … let! … return … }
  • ReturnFromLastNode → the trailing Task is returned directly (member _.M(...) = svc.FooAsync(args)) — no task { } / return!
  • None + non-generic Task return → side-effect frames then a trailing Task.CompletedTask (the synchronous-handler-with-a-Task-signature shape)
  • let mutableMethodCall.AssignResultTo marks its target Mutable, so a reassigned local renders let mutable x = … then x <- … (no-op on the C# path and for args/injected fields)
  • open coverage → derived from injected fields plus base/interface/return/argument types (recursing into generic args), via an F#-only AllReferencedFSharpNamespaces

Fixtures + command + gate (Phase 4)

  • FSharpCodegenTarget (C# contracts), CodegenTests.FSharpFixture (checked-in .fsproj + committed Generated.fs), CodegenTests.FSharp (the codegen-fsharp command, sample builder, and the integration test that regenerates Generated.fs and shells out to dotnet build).
  • The integration-test project is intentionally not wired into ./build.sh / Nuke test targets. Run it with dotnet test src/CodegenTests.FSharp. The gate retries once on a transient internal F# compiler crash (FS0193) — a real error in Generated.fs is deterministic and still fails.
  • The fixture gates 9 generated types: sync, task { } async, direct-async (ReturnFromLastNode), sync-Task (Task.CompletedTask), let mutable reassignment, if/then, if/else null guard, try/finally, and a Wolverine-shaped async handler (construct → do! save → sync factory → return).

Acceptance criteria

  • All generated F# fixture types compile via dotnet build (exit 0)
  • Full C# suite stays green — CodegenTests 389, CoreTests 439, CommandLineTests 285, EventTests 302 (net9/net10), + AOT smoke
  • Every unimplemented frame throws a clear NotSupportedException naming itself

Still deferred (guarded by the default-throw seam)

  • Constructor property setters, value-tuples, ActivityEvents
  • Task<T> returned from a synchronous body (would need Task.FromResult)
  • Generics-heavy frames and the full Wolverine frame set — to be exercised against Wolverine via a local project reference before any nuget

Commits

  1. M1 — F# code generation
  2. async task { } gate
  3. let mutable + idiomatic ReturnFromLastNode
  4. control-flow frames (if / if-else null guard / try-finally)
  5. broaden open coverage
  6. Wolverine-shaped async handler fixture
  7. synchronous Task-returning methods (Task.CompletedTask) + gate hardening

🤖 Generated with Claude Code

jeremydmiller and others added 7 commits May 27, 2026 10:25
Milestone 1: emit F# as an alternative to C# for the static (pre-generated)
code model only — never in-memory Roslyn compilation. The C# path is untouched;
every new per-frame member is virtual with a default-throw NotSupportedException
so existing frames keep compiling and working.

Phase 1 — writer + seam:
- FSharpSourceWriter : ISourceWriter, where BLOCK/END/FinishBlock indent/dedent
  without emitting braces (F# significant whitespace); the ISourceWriter
  interface proved F#-sufficient (no IFSharpSourceWriter needed).
- Frame.GenerateFSharpCode virtual default-throw (names the frame).
- Variable.FSharpAssignmentUsage + Mutable flag (let vs let mutable).
- Type.FSharpName() with F# primitive aliases (double->float, void->unit, ...),
  arrays, closed generics; throws loudly on open generics / tuples / by-ref.

Phase 2 — outer emit layers:
- GeneratedAssembly.GenerateFSharpCode (namespace + open + types at column 0).
- GeneratedType.WriteFSharp (primary ctor, let-bound fields, interface-member
  grouping, base-class inherit).
- GeneratedMethod.WriteFSharpMethod (member signature; drives the same arranged
  frame chain; sync body + task { } async wrapper with literal CE braces).

Phase 3 — five frames: CommentFrame, CodeFrame, ConstructorFrame, MethodCall,
ReturnFrame. F# is expression-oriented, so ReturnFrame emits a bare trailing
expression when synchronous and `return x` only inside a task block.

Phase 4 — fixtures + command + gate:
- FSharpCodegenTarget (C# contracts), CodegenTests.FSharpFixture (checked-in
  .fsproj + committed Generated.fs), CodegenTests.FSharp (codegen-fsharp command,
  sample builder, and an integration test that regenerates Generated.fs and
  shells out to `dotnet build`, asserting exit 0). The integration-test project
  is intentionally NOT wired into ./build.sh / Nuke test targets.

Acceptance: a trivial generated F# type compiles (dotnet build exit 0), the full
C# suite stays green, and unimplemented frames throw a clear NotSupportedException
naming themselves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an async contract (IAsyncGreeter + GreetingService.CreateGreetingAsync) and a
second generated type to the fixture sample whose method is asynchronous, so the
body is wrapped in a `task { }` computation expression with `let!` (await) and
`return`. Regenerated Generated.fs now contains both the synchronous and the
asynchronous type, and the compile gate proves both compile (dotnet build exit 0).
Also adds a fast async-emit unit test in CodegenTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…LastNode (#383)

Unify the F# emit rule: a `task { }` computation expression is emitted iff
AsyncMode == AsyncTask; everything else is a bare F# expression body.

let mutable (ReturnAction.Assign):
- MethodCall.AssignResultTo now marks the target Variable as Mutable, so its first
  binding renders `let mutable x = ...` and the assignment site renders `x <- ...`.
  Harmless on the C# path and a no-op for arguments/injected fields (never rendered
  via FSharpAssignmentUsage).

Idiomatic ReturnFromLastNode:
- When the single trailing async call IS the return value, emit the Task expression
  directly (e.g. `member _.M(...) = svc.FooAsync(args)`) instead of wrapping it in
  `task { return! ... }` — no unnecessary state machine.

Fixture gains GeneratedDirectAsyncGreeter (bare trailing Task) and GeneratedAccumulator
(let mutable + <-); both compile via the gate. Adds fast emit unit tests for each.
Full C# suite stays green (CodegenTests 383, CoreTests 439, + AOT smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#383)

Add GenerateFSharpCode to the control-flow frames, mapping C# statement blocks onto
F# expressions:
- CompositeFrame: parallel F# chaining + a `generateFSharpCode` hook (default-throw,
  naming the frame) for subclasses; GenerateCode stays sealed/untouched.
- IfBlock: `if <condition> then` with a brace-free indented body (condition is a raw
  passthrough, like CodeFrame).
- IfElseNullGuardFrame: `if isNull x then ... else ...` (an expression — both branches
  yield the trailing value), and its nested IfNullGuardFrame: `if not (isNull x) then`.
- TryFinallyWrapperFrame: `try <body> finally <cleanup>`.

Gated by three new fixture types (GeneratedConditionalGreeter, GeneratedToggle,
GeneratedResourceRunner) that compile via `dotnet build`, plus fast emit unit tests.
Full C# suite stays green (CodegenTests 386, CoreTests 439, + AOT smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GenerateFSharpCode now emits `open` statements derived from each generated type's
base type, implemented interfaces, and every generated method's return type and
argument types (recursing into generic arguments) — not just injected-field
namespaces. Implemented as an F#-only GeneratedAssembly.AllReferencedFSharpNamespaces
so the shared C# AllReferencedNamespaces / GenerateCode path is untouched.

All emitted F# names remain fully qualified, so these opens are convenience rather
than a correctness requirement. Generated.fs now also opens System and
System.Threading.Tasks. Adds a unit test; fixture still compiles via the gate; full
C# suite green (the lone CommandLineTests blip was the known-flaky console-capture
test CommandExecutorTester, which passes on isolated/repeat runs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A representative multi-frame handler that surfaces gaps before the real Wolverine
frame set: construct a domain object from the command, `do!` an async repository
save, build a confirmation with a sync factory call, and `return` it — all inside a
single `task { }` body (mixed sync + async frames). Gated via `dotnet build` and a
fast emit unit test. No core library changes; CodegenTests 388 green on net9/net10.

Generated shape:
    member _.Handle(command: PlaceOrder) : Task<OrderConfirmation> =
        task {
            let order = FSharpCodegenTarget.Order(command)
            do! _orderRepository.SaveAsync(order)
            let orderConfirmation = _confirmationFactory.Create(order)
            return orderConfirmation
        }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate (#383)

WriteFSharpMethod now completes the async-mode matrix: a synchronous body behind a
non-generic Task signature (AsyncMode.None, no ReturnVariable) emits the side-effect
frames followed by a trailing `System.Threading.Tasks.Task.CompletedTask` — the F#
equivalent of C#'s `return Task.CompletedTask;`. This is the common Wolverine
"synchronous handler with a Task signature" shape.

Gated by a new GeneratedSyncTaskHandler fixture type + a fast unit test.

Also harden the compile gate: a nested `dotnet build` (build-inside-test) can rarely
trip an internal F# compiler crash (FS0193 in the auto-generated AssemblyAttributes.fs)
unrelated to the generated source. The gate now retries the build once on that specific
internal-error signature only — a genuine F# error in Generated.fs is deterministic and
persists across the retry, so this cannot mask a real failure.

Full C# suite green (CodegenTests 389, CoreTests 439, + AOT smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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