From 94e41c1191b306e9dce325233e49762f0f558f15 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:25:35 -0500 Subject: [PATCH 1/7] Add F# code generation for the pre-generated code model (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- jasperfx.sln | 45 ++++++ .../CodegenTests.FSharp.csproj | 37 +++++ .../FSharpCodegenSample.cs | 79 +++++++++++ .../FSharpCompilationGate.cs | 63 +++++++++ .../GenerateFSharpCodeCommand.cs | 39 ++++++ .../CodegenTests.FSharpFixture.fsproj | 28 ++++ src/CodegenTests.FSharpFixture/Generated.fs | 17 +++ src/CodegenTests/FSharpGenerationTests.cs | 76 +++++++++++ src/CodegenTests/FSharpNameTests.cs | 61 +++++++++ src/CodegenTests/FSharpSourceWriterTests.cs | 79 +++++++++++ src/FSharpCodegenTarget/Contracts.cs | 35 +++++ .../FSharpCodegenTarget.csproj | 15 ++ .../FSharp/FSharpSourceWriter.cs | 128 ++++++++++++++++++ .../CodeGeneration/Frames/CodeFrame.cs | 12 ++ .../CodeGeneration/Frames/ConstructorFrame.cs | 61 +++++++++ src/JasperFx/CodeGeneration/Frames/Frame.cs | 21 +++ .../CodeGeneration/Frames/MethodCall.cs | 88 ++++++++++++ .../CodeGeneration/Frames/ReturnFrame.cs | 11 ++ .../CodeGeneration/FramesCollection.cs | 8 ++ .../CodeGeneration/GeneratedAssembly.cs | 37 +++++ .../CodeGeneration/GeneratedMethod.cs | 50 +++++++ src/JasperFx/CodeGeneration/GeneratedType.cs | 65 +++++++++ src/JasperFx/CodeGeneration/Model/Variable.cs | 15 ++ .../Core/Reflection/TypeNameExtensions.cs | 84 ++++++++++++ 24 files changed, 1154 insertions(+) create mode 100644 src/CodegenTests.FSharp/CodegenTests.FSharp.csproj create mode 100644 src/CodegenTests.FSharp/FSharpCodegenSample.cs create mode 100644 src/CodegenTests.FSharp/FSharpCompilationGate.cs create mode 100644 src/CodegenTests.FSharp/GenerateFSharpCodeCommand.cs create mode 100644 src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj create mode 100644 src/CodegenTests.FSharpFixture/Generated.fs create mode 100644 src/CodegenTests/FSharpGenerationTests.cs create mode 100644 src/CodegenTests/FSharpNameTests.cs create mode 100644 src/CodegenTests/FSharpSourceWriterTests.cs create mode 100644 src/FSharpCodegenTarget/Contracts.cs create mode 100644 src/FSharpCodegenTarget/FSharpCodegenTarget.csproj create mode 100644 src/JasperFx/CodeGeneration/FSharp/FSharpSourceWriter.cs diff --git a/jasperfx.sln b/jasperfx.sln index 7109d0e6..e152bf51 100644 --- a/jasperfx.sln +++ b/jasperfx.sln @@ -77,6 +77,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JasperFx.SourceGenerator", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JasperFx.SourceGenerator.Tests", "src\JasperFx.SourceGenerator.Tests\JasperFx.SourceGenerator.Tests.csproj", "{2952E461-E26E-4B9F-83D1-C4D1944B1F7C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FSharpCodegenTarget", "src\FSharpCodegenTarget\FSharpCodegenTarget.csproj", "{10EEDBCD-9A90-437A-A30B-D4D712126289}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CodegenTests.FSharpFixture", "src\CodegenTests.FSharpFixture\CodegenTests.FSharpFixture.fsproj", "{610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodegenTests.FSharp", "src\CodegenTests.FSharp\CodegenTests.FSharp.csproj", "{818BB6C9-1E47-4939-8F7B-59858CFB5B1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -417,6 +423,42 @@ Global {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|x64.Build.0 = Release|Any CPU {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|x86.ActiveCfg = Release|Any CPU {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|x86.Build.0 = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|x64.ActiveCfg = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|x64.Build.0 = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|x86.ActiveCfg = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Debug|x86.Build.0 = Debug|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|Any CPU.Build.0 = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|x64.ActiveCfg = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|x64.Build.0 = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|x86.ActiveCfg = Release|Any CPU + {10EEDBCD-9A90-437A-A30B-D4D712126289}.Release|x86.Build.0 = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|x64.Build.0 = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Debug|x86.Build.0 = Debug|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|Any CPU.Build.0 = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|x64.ActiveCfg = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|x64.Build.0 = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|x86.ActiveCfg = Release|Any CPU + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8}.Release|x86.Build.0 = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|x64.Build.0 = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Debug|x86.Build.0 = Debug|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|x64.ActiveCfg = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|x64.Build.0 = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|x86.ActiveCfg = Release|Any CPU + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -438,5 +480,8 @@ Global {8FB8216F-216F-480F-9519-A5893F7F3151} = {A1BBEFB2-1FDB-4A32-8D00-DD5AA8E1E43A} {E48F04F5-A8A3-4C47-9965-53C9D5DDC806} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2952E461-E26E-4B9F-83D1-C4D1944B1F7C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {10EEDBCD-9A90-437A-A30B-D4D712126289} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {610F3BB6-CD20-4AFB-8B60-2B1632ED97D8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {818BB6C9-1E47-4939-8F7B-59858CFB5B1C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/CodegenTests.FSharp/CodegenTests.FSharp.csproj b/src/CodegenTests.FSharp/CodegenTests.FSharp.csproj new file mode 100644 index 00000000..5c780e5c --- /dev/null +++ b/src/CodegenTests.FSharp/CodegenTests.FSharp.csproj @@ -0,0 +1,37 @@ + + + + + + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs new file mode 100644 index 00000000..8cc70f80 --- /dev/null +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using FSharpCodegenTarget; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; + +namespace CodegenTests.FSharp; + +/// +/// Builds the canonical "milestone 1" for F# code +/// generation (jasperfx#383): a sealed type that implements a C# interface, takes a +/// dependency through its constructor, constructs a value object, calls the injected +/// service, and returns a result. This single sample exercises all five frames wired up +/// for F# emit — , , +/// , , and — +/// plus constructor injection and interface implementation. +/// +public static class FSharpCodegenSample +{ + public static GeneratedAssembly BuildSampleAssembly() + { + var assembly = new GeneratedAssembly(new GenerationRules("FSharpCodegenTarget.Generated")); + + var type = assembly.AddType("GeneratedGreeter", typeof(IGreeter)); + var method = type.MethodFor(nameof(IGreeter.Greet)); + + // 1. A leading comment. + method.Frames.Add(new CommentFrame("Generated by JasperFx F# code generation (jasperfx#383)")); + + // 2. Construct a value object from the method argument: let salutation = Salutation(name) + var salutationCtor = typeof(Salutation).GetConstructors().Single(); + method.Frames.Add(new ConstructorFrame(typeof(Salutation), salutationCtor)); + + // 3. Call the constructor-injected service: + // let result_of_CreateGreeting = _greetingService.CreateGreeting(salutation) + var service = new InjectedField(typeof(GreetingService), "greetingService"); + var call = new MethodCall(typeof(GreetingService), nameof(GreetingService.CreateGreeting)) + { + Target = service + }; + method.Frames.Add(call); + + // 4. A raw code line that creates a new variable: let result = result_of_CreateGreeting.ToUpper() + var result = new Variable(typeof(string), "result"); + var codeFrame = (CodeFrame)method.Frames.Code("let {0} = {1}.ToUpper()", result, call.ReturnVariable!); + ((ICodeFrame)codeFrame).Creates(result); + + // 5. Return the result (rendered as a trailing F# expression). + method.Frames.Add(new ReturnFrame(result)); + + return assembly; + } + + /// + /// Builds the sample and renders it as F# source. + /// + public static string GenerateCode() + { + return BuildSampleAssembly().GenerateFSharpCode(); + } + + /// + /// The checked-in fixture's Generated.fs, located relative to this source file so it + /// resolves regardless of the test runner's working directory or bin layout. + /// + public static string DefaultGeneratedFilePath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcDir, "CodegenTests.FSharpFixture", "Generated.fs"); + } + + public static string FixtureProjectPath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcDir, "CodegenTests.FSharpFixture", "CodegenTests.FSharpFixture.fsproj"); + } +} diff --git a/src/CodegenTests.FSharp/FSharpCompilationGate.cs b/src/CodegenTests.FSharp/FSharpCompilationGate.cs new file mode 100644 index 00000000..7e36f79c --- /dev/null +++ b/src/CodegenTests.FSharp/FSharpCompilationGate.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using Shouldly; +using Xunit.Abstractions; + +namespace CodegenTests.FSharp; + +/// +/// The milestone-1 acceptance gate for F# code generation (jasperfx#383): runs the +/// codegen-fsharp command to (re)generate the fixture's Generated.fs, then shells +/// out to dotnet build on the checked-in F# fixture and asserts a clean (exit 0) build. +/// This proves the emitted F# actually compiles with the in-box F# compiler — no extra CI tooling. +/// +public class FSharpCompilationGate +{ + private readonly ITestOutputHelper _output; + + public FSharpCompilationGate(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void generated_fsharp_compiles_via_dotnet_build() + { + // 1. Run the command exactly as a developer would, writing Generated.fs into the fixture. + var command = new GenerateFSharpCodeCommand(); + command.Execute(new FSharpCodegenInput()).ShouldBeTrue(); + + var generatedFile = FSharpCodegenSample.DefaultGeneratedFilePath(); + File.Exists(generatedFile).ShouldBeTrue(); + _output.WriteLine(File.ReadAllText(generatedFile)); + + // 2. Compile the fixture with the F# compiler that ships in the SDK. + var fixtureProject = FSharpCodegenSample.FixtureProjectPath(); + var (exitCode, output) = RunDotnet($"build \"{fixtureProject}\" -c Debug --nologo"); + + _output.WriteLine(output); + exitCode.ShouldBe(0); + } + + private static (int ExitCode, string Output) RunDotnet(string arguments) + { + var info = new ProcessStartInfo("dotnet", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + // A nested `dotnet build` reusing MSBuild server nodes can hang the child; disable it so + // the build runs (and exits) in-process and we always get a deterministic exit code. + info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + info.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = Process.Start(info)!; + + // Read both streams concurrently to avoid a deadlock if either child buffer fills. + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + + return (process.ExitCode, stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult()); + } +} diff --git a/src/CodegenTests.FSharp/GenerateFSharpCodeCommand.cs b/src/CodegenTests.FSharp/GenerateFSharpCodeCommand.cs new file mode 100644 index 00000000..344f5358 --- /dev/null +++ b/src/CodegenTests.FSharp/GenerateFSharpCodeCommand.cs @@ -0,0 +1,39 @@ +using JasperFx.CommandLine; + +namespace CodegenTests.FSharp; + +public class FSharpCodegenInput +{ + [Description("Optional explicit path for the generated Generated.fs file")] + public string? FileFlag { get; set; } +} + +/// +/// The codegen-fsharp command (jasperfx#383). Builds the fixed sample +/// , renders it as F# via +/// , and writes +/// Generated.fs into the checked-in fixture project. The compile-gate test invokes +/// directly to regenerate the fixture before building it; the command +/// shape (a real ) is kept so it can later be registered with +/// a CLI host once the F# emit layer graduates out of the test fixtures. +/// +[Description("Generate the canonical F# code-generation sample into the checked-in fixture (jasperfx#383)", + Name = "codegen-fsharp")] +public class GenerateFSharpCodeCommand : JasperFxCommand +{ + public GenerateFSharpCodeCommand() + { + Usage("Generate the F# sample").Arguments(); + } + + public override bool Execute(FSharpCodegenInput input) + { + var path = input.FileFlag ?? FSharpCodegenSample.DefaultGeneratedFilePath(); + var code = FSharpCodegenSample.GenerateCode(); + + File.WriteAllText(path, code); + Console.WriteLine($"Wrote generated F# to {path}"); + + return true; + } +} diff --git a/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj b/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj new file mode 100644 index 00000000..9eab3874 --- /dev/null +++ b/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj @@ -0,0 +1,28 @@ + + + + + + false + + net10.0 + + + false + false + + + + + + + + + + + diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs new file mode 100644 index 00000000..f70f575f --- /dev/null +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -0,0 +1,17 @@ +// + +namespace FSharpCodegenTarget.Generated + +open FSharpCodegenTarget + +type GeneratedGreeter(greetingService: FSharpCodegenTarget.GreetingService) = + let _greetingService = greetingService + + interface FSharpCodegenTarget.IGreeter with + member _.Greet(name: string) : string = + // Generated by JasperFx F# code generation (jasperfx#383) + let salutation = FSharpCodegenTarget.Salutation(name) + let result_of_CreateGreeting = _greetingService.CreateGreeting(salutation) + let result = result_of_CreateGreeting.ToUpper() + result + diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs new file mode 100644 index 00000000..50b3c418 --- /dev/null +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -0,0 +1,76 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.FSharp; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Shouldly; + +namespace CodegenTests; + +public interface IFSharpGreeter +{ + string Greet(string name); +} + +public class FSharpGreetingService +{ + public string CreateGreeting(string name) + { + return "Hello " + name; + } +} + +public class FSharpGenerationTests +{ + [Fact] + public void emits_expected_fsharp_for_a_type_implementing_an_interface_with_an_injected_service() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedGreeter", typeof(IFSharpGreeter)); + var method = type.MethodFor(nameof(IFSharpGreeter.Greet)); + + method.Frames.Add(new CommentFrame("a comment")); + + var service = new InjectedField(typeof(FSharpGreetingService), "service"); + var call = new MethodCall(typeof(FSharpGreetingService), nameof(FSharpGreetingService.CreateGreeting)) + { + Target = service + }; + method.Frames.Add(call); + method.Frames.Add(new ReturnFrame(call.ReturnVariable!)); + + var code = assembly.GenerateFSharpCode(); + + code.ShouldContain("namespace Some.Generated"); + code.ShouldContain("type GeneratedGreeter(service: CodegenTests.FSharpGreetingService) ="); + code.ShouldContain("let _service = service"); + code.ShouldContain("interface CodegenTests.IFSharpGreeter with"); + code.ShouldContain("member _.Greet(name: string) : string ="); + code.ShouldContain("// a comment"); + code.ShouldContain("let result_of_CreateGreeting = _service.CreateGreeting(name)"); + + // F# is expression oriented: the body ends with a bare trailing expression, no `return`, + // and there are no C# braces or semicolons anywhere. + code.ShouldNotContain("return "); + code.ShouldNotContain("{"); + code.ShouldNotContain(";"); + } + + [Fact] + public void unimplemented_frame_throws_a_NotSupportedException_naming_itself() + { + var frame = new UnsupportedFrame(); + + var ex = Should.Throw(() => + frame.GenerateFSharpCode(null!, new FSharpSourceWriter())); + + ex.Message.ShouldContain(nameof(UnsupportedFrame)); + } + + public class UnsupportedFrame : SyncFrame + { + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + // no-op; intentionally does NOT override GenerateFSharpCode + } + } +} diff --git a/src/CodegenTests/FSharpNameTests.cs b/src/CodegenTests/FSharpNameTests.cs new file mode 100644 index 00000000..b0278fec --- /dev/null +++ b/src/CodegenTests/FSharpNameTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using JasperFx.Core.Reflection; +using Shouldly; + +namespace CodegenTests; + +public class FSharpNameTests +{ + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(long), "int64")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(object), "obj")] + [InlineData(typeof(void), "unit")] + [InlineData(typeof(decimal), "decimal")] + public void primitive_aliases(Type type, string expected) + { + type.FSharpName().ShouldBe(expected); + } + + [Fact] + public void double_is_float_and_single_is_float32_unlike_csharp() + { + typeof(double).FSharpName().ShouldBe("float"); + typeof(float).FSharpName().ShouldBe("float32"); + } + + [Fact] + public void arrays() + { + typeof(int[]).FSharpName().ShouldBe("int[]"); + typeof(string[]).FSharpName().ShouldBe("string[]"); + } + + [Fact] + public void closed_generics() + { + typeof(List).FSharpName().ShouldBe("System.Collections.Generic.List"); + typeof(Dictionary).FSharpName() + .ShouldBe("System.Collections.Generic.Dictionary"); + } + + [Fact] + public void non_primitive_falls_back_to_fully_qualified_name() + { + typeof(FSharpNameTests).FSharpName().ShouldBe("CodegenTests.FSharpNameTests"); + } + + [Fact] + public void throws_on_open_generic() + { + Should.Throw(() => typeof(List<>).FSharpName()); + } + + [Fact] + public void throws_on_tuple_for_now() + { + Should.Throw(() => typeof((int, string)).FSharpName()); + } +} diff --git a/src/CodegenTests/FSharpSourceWriterTests.cs b/src/CodegenTests/FSharpSourceWriterTests.cs new file mode 100644 index 00000000..5b1dc8d8 --- /dev/null +++ b/src/CodegenTests/FSharpSourceWriterTests.cs @@ -0,0 +1,79 @@ +using JasperFx.CodeGeneration.FSharp; +using JasperFx.Core; +using Shouldly; + +namespace CodegenTests; + +public class FSharpSourceWriterTests +{ + [Fact] + public void block_indents_without_emitting_a_brace() + { + var writer = new FSharpSourceWriter(); + writer.Write("BLOCK:type Foo() ="); + writer.Write("let x = 0"); + + var lines = writer.Code().ReadLines().ToArray(); + + lines[0].ShouldBe("type Foo() ="); + // No opening brace line, just an indented body line + lines[1].ShouldBe(" let x = 0"); + } + + [Fact] + public void end_dedents_without_emitting_a_brace() + { + var writer = new FSharpSourceWriter(); + writer.Write("BLOCK:type Foo() ="); + writer.Write("let x = 0"); + writer.Write("END"); + writer.Write("let y = 0"); + + var lines = writer.Code().ReadLines().ToArray(); + + lines.ShouldNotContain("}"); + // back at column 0 after END + lines.ShouldContain("let y = 0"); + } + + [Fact] + public void finish_block_only_dedents() + { + var writer = new FSharpSourceWriter(); + writer.Write("BLOCK:type Foo() ="); + writer.IndentionLevel.ShouldBe(1); + writer.FinishBlock(); + writer.IndentionLevel.ShouldBe(0); + + writer.Code().ShouldNotContain("}"); + } + + [Fact] + public void multi_level_indention() + { + var writer = new FSharpSourceWriter(); + writer.Write("BLOCK:type Foo() ="); + writer.Write("BLOCK:interface IBar with"); + writer.Write("member _.M() : int = 0"); + + var lines = writer.Code().ReadLines().ToArray(); + + lines[2].ShouldBe(" member _.M() : int = 0"); + } + + [Fact] + public void substitutes_backticks_for_double_quotes_like_the_csharp_writer() + { + var writer = new FSharpSourceWriter(); + writer.Write("let greeting = `hello`"); + + writer.Code().Trim().ShouldBe("let greeting = \"hello\""); + } + + [Fact] + public void finish_block_at_zero_throws() + { + var writer = new FSharpSourceWriter(); + Should.Throw(() => writer.FinishBlock()); + } +} diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs new file mode 100644 index 00000000..666aaaa6 --- /dev/null +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -0,0 +1,35 @@ +namespace FSharpCodegenTarget; + +/// +/// The interface the generated F# type implements — stands in for a Wolverine +/// handler/endpoint contract. +/// +public interface IGreeter +{ + string Greet(string name); +} + +/// +/// A simple value object the generated method constructs (exercises ConstructorFrame). +/// +public class Salutation +{ + public Salutation(string name) + { + Text = "Hello " + name; + } + + public string Text { get; } +} + +/// +/// A dependency the generated type takes through its constructor and calls +/// (exercises constructor injection + MethodCall). +/// +public class GreetingService +{ + public string CreateGreeting(Salutation salutation) + { + return salutation.Text + "!"; + } +} diff --git a/src/FSharpCodegenTarget/FSharpCodegenTarget.csproj b/src/FSharpCodegenTarget/FSharpCodegenTarget.csproj new file mode 100644 index 00000000..defa6bba --- /dev/null +++ b/src/FSharpCodegenTarget/FSharpCodegenTarget.csproj @@ -0,0 +1,15 @@ + + + + false + + + + + diff --git a/src/JasperFx/CodeGeneration/FSharp/FSharpSourceWriter.cs b/src/JasperFx/CodeGeneration/FSharp/FSharpSourceWriter.cs new file mode 100644 index 00000000..07ece215 --- /dev/null +++ b/src/JasperFx/CodeGeneration/FSharp/FSharpSourceWriter.cs @@ -0,0 +1,128 @@ +using System.Buffers; +using System.Text; +using JasperFx.Core; + +namespace JasperFx.CodeGeneration.FSharp; + +/// +/// An that lays out F# instead of C#. The only behavioral +/// difference from the C# is brace handling: a "block" +/// (BLOCK: marker or ) indents/dedents without +/// emitting any { / } characters, because F# uses significant whitespace for +/// type / member / interface scoping. Line writing, indentation, and the +/// backtick-to-double-quote substitution behave identically to . +/// +/// +/// Real F# braces (the task { } computation expression in particular) are genuine +/// syntax rather than scoping braces, so the emit code writes those as literal +/// WriteLine("{") / WriteLine("}") with manual +/// adjustment, bypassing the brace-free block protocol below. +/// +public class FSharpSourceWriter : ISourceWriter, IDisposable +{ + private const int IndentSize = 4; + private readonly StringBuilder _builder; + + public FSharpSourceWriter() + { + _builder = CodeGenerationObjectPool.StringBuilderPool.Get(); + } + + public void Dispose() + { + CodeGenerationObjectPool.StringBuilderPool.Return(_builder); + } + + public int IndentionLevel { get; set; } + + public void BlankLine() + { + _builder.AppendLine(); + } + + public void Write(string? text = null) + { + if (text.IsEmpty()) + { + BlankLine(); + return; + } + + foreach (var (line, _) in text.SplitLines()) + { + var buffer = ArrayPool.Shared.Rent(line.Length); + try + { + // constrain the span to the string length, this is important as the buffer returned might be larger than we need + var bufferSpan = buffer.AsSpan(0, line.Length); + line.Replace(bufferSpan, '`', '"'); + + if (bufferSpan.IsEmpty) + { + BlankLine(); + } + else if (bufferSpan.StartsWith("BLOCK:")) + { + // F#: emit the line and indent, but DO NOT open a '{' + WriteLine(bufferSpan.Slice(6)); + IndentionLevel++; + } + else if (bufferSpan.StartsWith("END")) + { + // F#: dedent only, no closing '}' + FinishBlock(); + } + else + { + WriteLine(bufferSpan); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + public void FinishBlock(ReadOnlySpan extra = default) + { + if (IndentionLevel == 0) + { + throw new InvalidOperationException("Not currently in a code block"); + } + + // F# has no closing brace, so just dedent. The C#-only `extra` (e.g. "});") + // is never passed by F# emit code and is intentionally ignored here. + IndentionLevel--; + } + + public void WriteLine(string text) + { + Indent(); + _builder.AppendLine(text); + } + + public void WriteLine(ReadOnlySpan value) + { + Indent(); + _builder.Append(value); + _builder.AppendLine(); + } + + public void WriteLine(char value) + { + Indent(); + _builder.Append(value); + _builder.AppendLine(); + } + + private void Indent() + { + _builder.Append(' ', IndentionLevel * IndentSize); + } + + public string Code() + { + return _builder.ToString(); + } +} diff --git a/src/JasperFx/CodeGeneration/Frames/CodeFrame.cs b/src/JasperFx/CodeGeneration/Frames/CodeFrame.cs index c1020e53..55166379 100644 --- a/src/JasperFx/CodeGeneration/Frames/CodeFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/CodeFrame.cs @@ -60,6 +60,18 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // CodeFrame is a raw passthrough of caller-supplied text, so the only + // difference from the C# path is chaining to Next via GenerateFSharpCode. + // It is the caller's responsibility to supply F#-valid text. + var substitutions = _values.Select(CodeFormatter.Write).ToArray(); + var code = string.Format(_format, substitutions); + + writer.Write(code); + Next?.GenerateFSharpCode(method, writer); + } + public override IEnumerable FindVariables(IMethodVariables chain) { for (var i = 0; i < _values.Length; i++) diff --git a/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs b/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs index 62071676..528be5d1 100644 --- a/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs @@ -183,6 +183,48 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + if (Header != null) + { + writer.WriteLine(""); + Header.Write(writer); + } + + var insideTaskBlock = method.AsyncMode != AsyncMode.None; + + switch (Mode) + { + case ConstructorCallMode.Variable: + writer.Write(FSharpDeclaration()); + ActivatorFrames.WriteFSharp(method, writer); + Next?.GenerateFSharpCode(method, writer); + break; + + case ConstructorCallMode.ReturnValue: + if (ActivatorFrames.Any()) + { + writer.Write(FSharpDeclaration()); + ActivatorFrames.WriteFSharp(method, writer); + writer.Write(insideTaskBlock ? $"return {Variable.Usage}" : Variable.Usage); + } + else + { + writer.Write(insideTaskBlock ? $"return {FSharpInvocation()}" : FSharpInvocation()); + } + + Next?.GenerateFSharpCode(method, writer); + break; + + case ConstructorCallMode.UsingNestedVariable: + // F# `use` gives the same deterministic disposal as C# `using`. + writer.Write($"use {FSharpDeclaration()}"); + ActivatorFrames.WriteFSharp(method, writer); + Next?.GenerateFSharpCode(method, writer); + break; + } + } + public string Declaration() { return DeclaredType == null @@ -201,6 +243,25 @@ public string Invocation() return invocation; } + public string FSharpDeclaration() + { + return DeclaredType == null + ? $"{Variable.FSharpAssignmentUsage} = {FSharpInvocation()}" + : $"let {Variable.Usage} : {DeclaredType.FSharpName()} = {FSharpInvocation()}"; + } + + public string FSharpInvocation() + { + if (Setters.Any()) + { + throw new NotSupportedException( + "F# code generation does not yet support constructor property setters."); + } + + // F# omits the `new` keyword for ordinary construction. + return $"{BuiltType.FSharpName()}({Parameters.Select(x => x.Usage).Join(", ")})"; + } + public override IEnumerable FindVariables(IMethodVariables chain) { var parameters = Ctor.GetParameters(); diff --git a/src/JasperFx/CodeGeneration/Frames/Frame.cs b/src/JasperFx/CodeGeneration/Frames/Frame.cs index 52d0499a..6fc8597a 100644 --- a/src/JasperFx/CodeGeneration/Frames/Frame.cs +++ b/src/JasperFx/CodeGeneration/Frames/Frame.cs @@ -1,5 +1,6 @@ using JasperFx.CodeGeneration.Model; using JasperFx.Core; +using JasperFx.Core.Reflection; namespace JasperFx.CodeGeneration.Frames; @@ -29,6 +30,13 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) // frame to let it generate its code Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // "// comment" is identical in C# and F# + writer.WriteComment(_commentText); + Next?.GenerateFSharpCode(method, writer); + } } #endregion @@ -109,6 +117,19 @@ public Variable Create(string name) public abstract void GenerateCode(GeneratedMethod method, ISourceWriter writer); + /// + /// EXPERIMENTAL alternative to that emits F# instead of C# + /// for the pre-generated (static) code model. The default throws so the proven C# path + /// and every frame that has not opted in keep compiling and working untouched; override + /// this per-frame to add F# support. Like , an implementation + /// is responsible for calling through to (when it has one). + /// + public virtual void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + throw new NotSupportedException( + $"F# code generation is not supported for the frame '{GetType().FullNameInCode()}'."); + } + public void ResolveVariables(IMethodVariables method) { // This has to be idempotent diff --git a/src/JasperFx/CodeGeneration/Frames/MethodCall.cs b/src/JasperFx/CodeGeneration/Frames/MethodCall.cs index c4727fb8..17757521 100644 --- a/src/JasperFx/CodeGeneration/Frames/MethodCall.cs +++ b/src/JasperFx/CodeGeneration/Frames/MethodCall.cs @@ -388,6 +388,94 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + if (CommentText.IsNotEmpty()) + { + writer.WriteLine(""); + writer.WriteComment(CommentText); + } + + // Activity events use C#-specific object-initializer syntax; they are + // intentionally not emitted on the F# path for milestone 1. + + writer.Write($"{fsharpReturnActionCode(method)}{fsharpInvocationCode()}"); + + if (CommentText.IsNotEmpty()) + { + writer.BlankLine(); + } + + Next?.GenerateFSharpCode(method, writer); + } + + private string fsharpInvocationCode() + { + var methodName = Method.Name; + if (Method.IsGenericMethod) + { + methodName += $"<{Method.GetGenericArguments().Select(x => x.FSharpName()).Join(", ")}>"; + } + + var callingCode = $"{methodName}({Arguments.Select(x => x.Usage).Join(", ")})"; + + return $"{fsharpDetermineTarget()}{callingCode}"; + } + + private string fsharpDetermineTarget() + { + if (IsLocal) + { + return string.Empty; + } + + var target = Method.IsStatic + ? HandlerType.FSharpName() + : Target!.Usage; + + return target + "."; + } + + private string fsharpReturnActionCode(GeneratedMethod method) + { + var insideTaskBlock = method.AsyncMode != AsyncMode.None; + + // The last async node returns the awaited result directly: F# `return!`. + if (IsAsync && method.AsyncMode == AsyncMode.ReturnFromLastNode) + { + return "return! "; + } + + if (ReturnVariable == null) + { + // A void async call must still be awaited inside the task block. + return IsAsync && insideTaskBlock ? "do! " : string.Empty; + } + + if (ReturnVariable.VariableType.IsValueTuple()) + { + throw new NotSupportedException( + "F# code generation does not yet support value-tuple return variables."); + } + + var awaited = IsAsync && insideTaskBlock; + + switch (ReturnAction) + { + case ReturnAction.Initialize: + // `let x =` (sync) or `let! x =` (await inside the task block) + return awaited ? $"let! {ReturnVariable.Usage} = " : $"{ReturnVariable.FSharpAssignmentUsage} = "; + case ReturnAction.Assign: + // F# reassignment of a `let mutable` binding uses the `<-` operator. + return awaited ? $"let! {ReturnVariable.Usage} = " : $"{ReturnVariable.Usage} <- "; + case ReturnAction.Return: + // Synchronous: the invocation IS the trailing expression (no `return`). + return insideTaskBlock ? "return! " : string.Empty; + } + + throw new ArgumentOutOfRangeException(); + } + private static void writeActivityEvent(ISourceWriter writer, string eventName) { writer.Write( diff --git a/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs b/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs index 09aaf1e5..8770650f 100644 --- a/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs @@ -29,6 +29,17 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) writer.Write(code); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // F# is expression-oriented: a synchronous method body ends with a bare + // trailing expression (no `return`), while inside a `task { }` computation + // expression you DO use `return`. `unit` is written as `()`. + var expression = ReturnedVariable == null ? "()" : ReturnedVariable.Usage; + var insideTaskBlock = method.AsyncMode != AsyncMode.None; + + writer.Write(insideTaskBlock ? $"return {expression}" : expression); + } + public override IEnumerable FindVariables(IMethodVariables chain) { if (ReturnedVariable == null && ReturnType != null) diff --git a/src/JasperFx/CodeGeneration/FramesCollection.cs b/src/JasperFx/CodeGeneration/FramesCollection.cs index bbe37b42..02519011 100644 --- a/src/JasperFx/CodeGeneration/FramesCollection.cs +++ b/src/JasperFx/CodeGeneration/FramesCollection.cs @@ -109,6 +109,14 @@ public void Write(GeneratedMethod method, ISourceWriter writer) foreach (var frame in this) frame.GenerateCode(method, writer); } + /// + /// EXPERIMENTAL F# counterpart to : emits each frame as F#. + /// + public void WriteFSharp(GeneratedMethod method, ISourceWriter writer) + { + foreach (var frame in this) frame.GenerateFSharpCode(method, writer); + } + /// /// Add a frame that just writes out "return null;" /// diff --git a/src/JasperFx/CodeGeneration/GeneratedAssembly.cs b/src/JasperFx/CodeGeneration/GeneratedAssembly.cs index c655a238..7bc9a00a 100644 --- a/src/JasperFx/CodeGeneration/GeneratedAssembly.cs +++ b/src/JasperFx/CodeGeneration/GeneratedAssembly.cs @@ -136,6 +136,43 @@ public string GenerateCode(IServiceVariableSource? services = null) return code; } + /// + /// EXPERIMENTAL F# counterpart to . Targets the pre-generated + /// (static) code model only — it never attempts in-memory Roslyn compilation. Emits an F# + /// file header, a single namespace declaration, open statements (instead of + /// C# using), and each generated type in declaration order (significant in F#). + /// + public string GenerateFSharpCode(IServiceVariableSource? services = null) + { + foreach (var generatedType in GeneratedTypes) + { + services?.StartNewType(); + generatedType.ArrangeFrames(services); + } + + var namespaces = AllReferencedNamespaces(); + + using var writer = new FSharp.FSharpSourceWriter(); + writer.WriteLine("// "); + writer.BlankLine(); + + // In F#, a namespace is a plain declaration; its types sit at column 0 (no brace, no indent). + writer.WriteLine($"namespace {Namespace}"); + writer.BlankLine(); + + foreach (var ns in namespaces.OrderBy(x => x)) writer.WriteLine($"open {ns}"); + + writer.BlankLine(); + + foreach (var @class in GeneratedTypes) + { + @class.WriteFSharp(writer); + writer.BlankLine(); + } + + return writer.Code(); + } + public List AllReferencedNamespaces() { var namespaces = GeneratedTypes diff --git a/src/JasperFx/CodeGeneration/GeneratedMethod.cs b/src/JasperFx/CodeGeneration/GeneratedMethod.cs index 478782c2..daeb6d95 100644 --- a/src/JasperFx/CodeGeneration/GeneratedMethod.cs +++ b/src/JasperFx/CodeGeneration/GeneratedMethod.cs @@ -63,6 +63,13 @@ public GeneratedMethod(string methodName, Type returnType, params Argument[] arg public bool Overrides { get; set; } + /// + /// The interface or base-class this generated method derives + /// from, or null for methods added directly (e.g. via AddVoidMethod). Used by + /// the F# emit layer to group interface members under the right interface ... with. + /// + internal MethodInfo? ParentMethodInfo => _parentMethod; + /// /// Is the method synchronous, returning a Task, or an async method /// @@ -181,6 +188,49 @@ public void WriteMethod(ISourceWriter writer) writer.FinishBlock(); } + /// + /// EXPERIMENTAL F# counterpart to . Emits an F# member (or + /// override) signature and drives the same arranged frame chain through + /// . Synchronous bodies are a sequence of + /// let bindings ending in a trailing expression; asynchronous bodies are wrapped + /// in a task { } computation expression. + /// + public void WriteFSharpMethod(ISourceWriter writer) + { + if (_top == null) + { + throw new InvalidOperationException( + $"You must call {nameof(ArrangeFrames)}() before writing out the source code"); + } + + Header?.Write(writer); + + var arguments = Arguments.Select(x => $"{x.Usage}: {x.VariableType.FSharpName()}").Join(", "); + var returnType = ReturnType.FSharpName(); + var keyword = Overrides ? "override" : "member"; + + // `_` self identifier avoids an unused-`this` warning; instance let-bound + // fields are reachable directly by name regardless of the self identifier. + writer.Write($"BLOCK:{keyword} _.{MethodName}({arguments}) : {returnType} ="); + + if (AsyncMode == AsyncMode.None) + { + _top.GenerateFSharpCode(this, writer); + } + else + { + // The `task { }` braces are genuine F# syntax, so they are written literally + // (with manual indentation) rather than through the brace-free block protocol. + writer.WriteLine("task {"); + writer.IndentionLevel++; + _top.GenerateFSharpCode(this, writer); + writer.IndentionLevel--; + writer.WriteLine("}"); + } + + writer.FinishBlock(); + } + protected void writeReturnStatement(ISourceWriter writer) { if (ReturnVariable != null) diff --git a/src/JasperFx/CodeGeneration/GeneratedType.cs b/src/JasperFx/CodeGeneration/GeneratedType.cs index a1018535..af31e061 100644 --- a/src/JasperFx/CodeGeneration/GeneratedType.cs +++ b/src/JasperFx/CodeGeneration/GeneratedType.cs @@ -223,6 +223,71 @@ public void Write(ISourceWriter writer) Footer?.Write(writer); } + /// + /// EXPERIMENTAL F# counterpart to . Emits an F# type with a + /// primary constructor, let-bound private fields for the injected dependencies, + /// base-class inherit, interface members grouped under interface ... with, + /// and any base-class override members at the type level. + /// + public void WriteFSharp(ISourceWriter writer) + { + Header?.Write(writer); + + var ctorArgs = AllInjectedFields + .Select(x => $"{x.CtorArg}: {x.ArgType.FSharpName()}") + .Join(", "); + + writer.Write($"BLOCK:type {TypeName}({ctorArgs}) ="); + + if (BaseType is { IsInterface: false }) + { + var baseArgs = BaseConstructorArguments.Select(x => x.Usage).Join(", "); + writer.WriteLine($"inherit {BaseType.FSharpName()}({baseArgs})"); + } + + foreach (var field in AllInjectedFields) + { + writer.WriteLine($"let {field.Usage} = {field.CtorArg}"); + } + + if (AllInjectedFields.Any()) + { + writer.BlankLine(); + } + + var generatable = Methods.Where(x => x.WillGenerate()).ToArray(); + + foreach (var @interface in Interfaces) + { + var members = generatable + .Where(m => m.ParentMethodInfo?.DeclaringType == @interface) + .ToArray(); + + if (!members.Any()) + { + continue; + } + + writer.Write($"BLOCK:interface {@interface.FSharpName()} with"); + foreach (var member in members) + { + member.WriteFSharpMethod(writer); + } + + writer.FinishBlock(); + } + + // Base-class overrides and directly-added methods are emitted as plain type members. + foreach (var member in generatable.Where(m => m.Overrides || m.ParentMethodInfo == null)) + { + member.WriteFSharpMethod(writer); + } + + writer.FinishBlock(); + + Footer?.Write(writer); + } + private void writeSetters(ISourceWriter writer) { foreach (var setter in Setters) diff --git a/src/JasperFx/CodeGeneration/Model/Variable.cs b/src/JasperFx/CodeGeneration/Model/Variable.cs index 0e450a41..a21db5da 100644 --- a/src/JasperFx/CodeGeneration/Model/Variable.cs +++ b/src/JasperFx/CodeGeneration/Model/Variable.cs @@ -149,6 +149,21 @@ protected set /// public virtual string AssignmentUsage => $"var {Usage}"; + /// + /// EXPERIMENTAL (F# code generation). Marks this variable as being reassigned after + /// its initial binding (e.g. ). + /// F# let bindings are immutable, so a reassigned variable must be declared with + /// let mutable instead. Has no effect on the C# generation path. + /// + public bool Mutable { get; set; } + + /// + /// EXPERIMENTAL (F# code generation). How the variable is declared+assigned in F#. Default + /// is $"let {Usage}"; a reassigned variable (see ) renders as + /// $"let mutable {Usage}". + /// + public virtual string FSharpAssignmentUsage => Mutable ? $"let mutable {Usage}" : $"let {Usage}"; + public virtual string ArgumentDeclaration => Usage; /// diff --git a/src/JasperFx/Core/Reflection/TypeNameExtensions.cs b/src/JasperFx/Core/Reflection/TypeNameExtensions.cs index 5d18b43d..bee7e52d 100644 --- a/src/JasperFx/Core/Reflection/TypeNameExtensions.cs +++ b/src/JasperFx/Core/Reflection/TypeNameExtensions.cs @@ -66,6 +66,90 @@ public static string FullNameInCode(this Type type) return type.FullName.Replace("+", "."); } + /// + /// F# keyword aliases for the primitive .NET types, used by . + /// Note the deliberate differences from C#: System.Double is float in F# + /// (and System.Single is float32), and System.Void is unit. + /// + public static readonly Dictionary FSharpAliases = new() + { + { typeof(void), "unit" }, + { typeof(object), "obj" }, + { typeof(string), "string" }, + { typeof(bool), "bool" }, + { typeof(char), "char" }, + { typeof(byte), "byte" }, + { typeof(sbyte), "sbyte" }, + { typeof(short), "int16" }, + { typeof(ushort), "uint16" }, + { typeof(int), "int" }, + { typeof(uint), "uint32" }, + { typeof(long), "int64" }, + { typeof(ulong), "uint64" }, + { typeof(float), "float32" }, + { typeof(double), "float" }, + { typeof(decimal), "decimal" }, + { typeof(nint), "nativeint" }, + { typeof(nuint), "unativeint" } + }; + + /// + /// EXPERIMENTAL. Derives the full type name *as it would appear in F# code*. Handles the + /// primitive aliases (see ), arrays, and closed generic types. + /// Throws on cases the F# code generator does not yet + /// handle (open generics, generic parameters, by-ref/pointer types, and tuple types) so + /// unsupported shapes fail loudly rather than emitting invalid F#. + /// + public static string FSharpName(this Type type) + { + if (FSharpAliases.TryGetValue(type, out var alias)) + { + return alias; + } + + if (type.IsByRef || type.IsPointer || type.IsGenericParameter) + { + throw new NotSupportedException( + $"F# code generation does not yet support the type '{type}'."); + } + + if (type.IsArray) + { + return $"{type.GetElementType()!.FSharpName()}[]"; + } + + if (type.IsGenericType) + { + if (type.IsGenericTypeDefinition) + { + throw new NotSupportedException( + $"F# code generation does not support the open generic type '{type}'."); + } + + var definition = type.GetGenericTypeDefinition(); + if (definition.FullName != null && + (definition.FullName.StartsWith("System.ValueTuple") || + definition.FullName.StartsWith("System.Tuple"))) + { + throw new NotSupportedException( + $"F# code generation does not yet support the tuple type '{type}'."); + } + + var cleanName = type.Name.Split('`').First(); + var args = type.GetGenericArguments().Select(x => x.FSharpName()).Join(", "); + + if (type.IsNested) + { + return $"{type.ReflectedType!.FSharpName()}.{cleanName}<{args}>"; + } + + return $"{type.Namespace}.{cleanName}<{args}>"; + } + + // Non-generic, non-primitive: the fully-qualified C# name is also valid F#. + return type.FullNameInCode(); + } + /// /// Derives the type name *as it would appear in C# code* /// From fc76e27d369e7275e2c6f157efa38fc17556c48d Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:28:57 -0500 Subject: [PATCH 2/7] Gate the async task { } F# emit path (#383) 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) --- .../FSharpCodegenSample.cs | 26 ++++++++++++++ src/CodegenTests.FSharpFixture/Generated.fs | 12 +++++++ src/CodegenTests/FSharpGenerationTests.cs | 36 +++++++++++++++++++ src/FSharpCodegenTarget/Contracts.cs | 14 ++++++++ 4 files changed, 88 insertions(+) diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index 8cc70f80..a05a1434 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -48,9 +48,35 @@ public static GeneratedAssembly BuildSampleAssembly() // 5. Return the result (rendered as a trailing F# expression). method.Frames.Add(new ReturnFrame(result)); + AddAsyncType(assembly); + return assembly; } + /// + /// A second type whose method is asynchronous, so the body is wrapped in a + /// task { } computation expression with let! (await) and return. + /// + private static void AddAsyncType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedAsyncGreeter", typeof(IAsyncGreeter)); + var method = type.MethodFor(nameof(IAsyncGreeter.GreetAsync)); + + method.Frames.Add(new CommentFrame("Async greeting handler (jasperfx#383)")); + + var salutationCtor = typeof(Salutation).GetConstructors().Single(); + method.Frames.Add(new ConstructorFrame(typeof(Salutation), salutationCtor)); + + var service = new InjectedField(typeof(GreetingService), "greetingService"); + var call = new MethodCall(typeof(GreetingService), nameof(GreetingService.CreateGreetingAsync)) + { + Target = service + }; + method.Frames.Add(call); + + method.Frames.Add(new ReturnFrame(call.ReturnVariable!)); + } + /// /// Builds the sample and renders it as F# source. /// diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index f70f575f..cf4cc089 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -15,3 +15,15 @@ type GeneratedGreeter(greetingService: FSharpCodegenTarget.GreetingService) = let result = result_of_CreateGreeting.ToUpper() result +type GeneratedAsyncGreeter(greetingService: FSharpCodegenTarget.GreetingService) = + let _greetingService = greetingService + + interface FSharpCodegenTarget.IAsyncGreeter with + member _.GreetAsync(name: string) : System.Threading.Tasks.Task = + task { + // Async greeting handler (jasperfx#383) + let salutation = FSharpCodegenTarget.Salutation(name) + let! result_of_CreateGreetingAsync = _greetingService.CreateGreetingAsync(salutation) + return result_of_CreateGreetingAsync + } + diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs index 50b3c418..1543bbb7 100644 --- a/src/CodegenTests/FSharpGenerationTests.cs +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -11,12 +11,22 @@ public interface IFSharpGreeter string Greet(string name); } +public interface IFSharpAsyncGreeter +{ + Task GreetAsync(string name); +} + public class FSharpGreetingService { public string CreateGreeting(string name) { return "Hello " + name; } + + public Task CreateGreetingAsync(string name) + { + return Task.FromResult("Hello " + name); + } } public class FSharpGenerationTests @@ -55,6 +65,32 @@ public void emits_expected_fsharp_for_a_type_implementing_an_interface_with_an_i code.ShouldNotContain(";"); } + [Fact] + public void wraps_an_async_method_body_in_a_task_computation_expression() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedAsyncGreeter", typeof(IFSharpAsyncGreeter)); + var method = type.MethodFor(nameof(IFSharpAsyncGreeter.GreetAsync)); + + var service = new InjectedField(typeof(FSharpGreetingService), "service"); + var call = new MethodCall(typeof(FSharpGreetingService), nameof(FSharpGreetingService.CreateGreetingAsync)) + { + Target = service + }; + method.Frames.Add(call); + method.Frames.Add(new ReturnFrame(call.ReturnVariable!)); + + var code = assembly.GenerateFSharpCode(); + + code.ShouldContain("member _.GreetAsync(name: string) : System.Threading.Tasks.Task ="); + code.ShouldContain("task {"); + // await inside the computation expression binds with let! + code.ShouldContain("let! result_of_CreateGreetingAsync = _service.CreateGreetingAsync(name)"); + // and the trailing expression uses `return` because we are inside the task block + code.ShouldContain("return result_of_CreateGreetingAsync"); + code.ShouldContain("}"); + } + [Fact] public void unimplemented_frame_throws_a_NotSupportedException_naming_itself() { diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index 666aaaa6..f3457068 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -9,6 +9,15 @@ public interface IGreeter string Greet(string name); } +/// +/// The async counterpart of , used to exercise the F# task { } +/// emit path. +/// +public interface IAsyncGreeter +{ + Task GreetAsync(string name); +} + /// /// A simple value object the generated method constructs (exercises ConstructorFrame). /// @@ -32,4 +41,9 @@ public string CreateGreeting(Salutation salutation) { return salutation.Text + "!"; } + + public Task CreateGreetingAsync(Salutation salutation) + { + return Task.FromResult(salutation.Text + "!"); + } } From 2dd7a752de8cfc7d6192bc615d7a7f833fb51018 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:41:02 -0500 Subject: [PATCH 3/7] Refine async F# emit: let mutable reassignment + idiomatic ReturnFromLastNode (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../FSharpCodegenSample.cs | 51 +++++++++++++ src/CodegenTests.FSharpFixture/Generated.fs | 18 +++++ src/CodegenTests/FSharpGenerationTests.cs | 71 +++++++++++++++++++ src/FSharpCodegenTarget/Contracts.cs | 32 +++++++++ .../CodeGeneration/Frames/ConstructorFrame.cs | 2 +- .../CodeGeneration/Frames/MethodCall.cs | 15 +++- .../CodeGeneration/Frames/ReturnFrame.cs | 2 +- .../CodeGeneration/GeneratedMethod.cs | 12 ++-- 8 files changed, 193 insertions(+), 10 deletions(-) diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index a05a1434..04b91d14 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -49,6 +49,8 @@ public static GeneratedAssembly BuildSampleAssembly() method.Frames.Add(new ReturnFrame(result)); AddAsyncType(assembly); + AddDirectAsyncType(assembly); + AddAccumulatorType(assembly); return assembly; } @@ -77,6 +79,55 @@ private static void AddAsyncType(GeneratedAssembly assembly) method.Frames.Add(new ReturnFrame(call.ReturnVariable!)); } + /// + /// A type whose single async call IS the return value, so the body is a bare trailing + /// Task expression (ReturnFromLastNode) — no task { } state machine. + /// + private static void AddDirectAsyncType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedDirectAsyncGreeter", typeof(IDirectAsyncGreeter)); + var method = type.MethodFor(nameof(IDirectAsyncGreeter.GreetDirectAsync)); + + method.Frames.Add(new CommentFrame("Returns the service Task directly (jasperfx#383)")); + + var salutationCtor = typeof(Salutation).GetConstructors().Single(); + method.Frames.Add(new ConstructorFrame(typeof(Salutation), salutationCtor)); + + var service = new InjectedField(typeof(GreetingService), "greetingService"); + var call = new MethodCall(typeof(GreetingService), nameof(GreetingService.CreateGreetingAsync)) + { + Target = service, + // The call itself is the method's return value — no ReturnFrame after it. + ReturnAction = ReturnAction.Return + }; + method.Frames.Add(call); + } + + /// + /// A type that constructs a local, reassigns it from a service call, and returns it — + /// exercising F# let mutable + the <- reassignment operator. + /// + private static void AddAccumulatorType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedAccumulator", typeof(IAccumulator)); + var method = type.MethodFor(nameof(IAccumulator.Accumulate)); + + var boxCtor = typeof(MutableBox).GetConstructors().Single(); + var ctorFrame = new ConstructorFrame(typeof(MutableBox), boxCtor); + method.Frames.Add(ctorFrame); + + var service = new InjectedField(typeof(AccumulatorService), "accumulatorService"); + var call = new MethodCall(typeof(AccumulatorService), nameof(AccumulatorService.Advance)) + { + Target = service + }; + call.Arguments[0] = ctorFrame.Variable; + call.AssignResultTo(ctorFrame.Variable); // box <- service.Advance(box) (marks box mutable) + method.Frames.Add(call); + + method.Frames.Add(new ReturnFrame(ctorFrame.Variable)); + } + /// /// Builds the sample and renders it as F# source. /// diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index cf4cc089..59edff75 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -27,3 +27,21 @@ type GeneratedAsyncGreeter(greetingService: FSharpCodegenTarget.GreetingService) return result_of_CreateGreetingAsync } +type GeneratedDirectAsyncGreeter(greetingService: FSharpCodegenTarget.GreetingService) = + let _greetingService = greetingService + + interface FSharpCodegenTarget.IDirectAsyncGreeter with + member _.GreetDirectAsync(name: string) : System.Threading.Tasks.Task = + // Returns the service Task directly (jasperfx#383) + let salutation = FSharpCodegenTarget.Salutation(name) + _greetingService.CreateGreetingAsync(salutation) + +type GeneratedAccumulator(accumulatorService: FSharpCodegenTarget.AccumulatorService) = + let _accumulatorService = accumulatorService + + interface FSharpCodegenTarget.IAccumulator with + member _.Accumulate() : FSharpCodegenTarget.MutableBox = + let mutable mutableBox = FSharpCodegenTarget.MutableBox() + mutableBox <- _accumulatorService.Advance(mutableBox) + mutableBox + diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs index 1543bbb7..43b8f2f8 100644 --- a/src/CodegenTests/FSharpGenerationTests.cs +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -29,6 +29,25 @@ public Task CreateGreetingAsync(string name) } } +public interface IFSharpAccumulator +{ + FSharpBox Accumulate(); +} + +public class FSharpBox +{ + public int Value { get; set; } +} + +public class FSharpAccumulatorService +{ + public FSharpBox Advance(FSharpBox box) + { + box.Value++; + return box; + } +} + public class FSharpGenerationTests { [Fact] @@ -91,6 +110,58 @@ public void wraps_an_async_method_body_in_a_task_computation_expression() code.ShouldContain("}"); } + [Fact] + public void emits_a_bare_trailing_task_expression_for_return_from_last_node() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedDirectAsyncGreeter", typeof(IFSharpAsyncGreeter)); + var method = type.MethodFor(nameof(IFSharpAsyncGreeter.GreetAsync)); + + var service = new InjectedField(typeof(FSharpGreetingService), "service"); + var call = new MethodCall(typeof(FSharpGreetingService), nameof(FSharpGreetingService.CreateGreetingAsync)) + { + Target = service, + ReturnAction = ReturnAction.Return + }; + method.Frames.Add(call); + + var code = assembly.GenerateFSharpCode(); + + code.ShouldContain("member _.GreetAsync(name: string) : System.Threading.Tasks.Task ="); + // No state machine: the Task is returned directly, with no task block and no `return!`. + code.ShouldNotContain("task {"); + code.ShouldNotContain("return!"); + code.ShouldContain("_service.CreateGreetingAsync(name)"); + } + + [Fact] + public void renders_let_mutable_and_reassignment_for_return_action_assign() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedAccumulator", typeof(IFSharpAccumulator)); + var method = type.MethodFor(nameof(IFSharpAccumulator.Accumulate)); + + var boxCtor = typeof(FSharpBox).GetConstructors().Single(); + var ctorFrame = new ConstructorFrame(typeof(FSharpBox), boxCtor); + method.Frames.Add(ctorFrame); + + var service = new InjectedField(typeof(FSharpAccumulatorService), "service"); + var call = new MethodCall(typeof(FSharpAccumulatorService), nameof(FSharpAccumulatorService.Advance)) + { + Target = service + }; + call.Arguments[0] = ctorFrame.Variable; + call.AssignResultTo(ctorFrame.Variable); + method.Frames.Add(call); + method.Frames.Add(new ReturnFrame(ctorFrame.Variable)); + + var code = assembly.GenerateFSharpCode(); + + var usage = ctorFrame.Variable.Usage; + code.ShouldContain($"let mutable {usage} = CodegenTests.FSharpBox()"); + code.ShouldContain($"{usage} <- _service.Advance({usage})"); + } + [Fact] public void unimplemented_frame_throws_a_NotSupportedException_naming_itself() { diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index f3457068..1bec2b1c 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -18,6 +18,38 @@ public interface IAsyncGreeter Task GreetAsync(string name); } +/// +/// Async interface whose generated implementation just returns the trailing Task expression +/// directly — used to exercise the idiomatic ReturnFromLastNode path (no task { }). +/// +public interface IDirectAsyncGreeter +{ + Task GreetDirectAsync(string name); +} + +/// +/// A mutable value object, reassigned in the generated method to exercise the F# +/// let mutable / <- path. +/// +public interface IAccumulator +{ + MutableBox Accumulate(); +} + +public class MutableBox +{ + public int Value { get; set; } +} + +public class AccumulatorService +{ + public MutableBox Advance(MutableBox box) + { + box.Value++; + return box; + } +} + /// /// A simple value object the generated method constructs (exercises ConstructorFrame). /// diff --git a/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs b/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs index 528be5d1..c081587d 100644 --- a/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs @@ -191,7 +191,7 @@ public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter wr Header.Write(writer); } - var insideTaskBlock = method.AsyncMode != AsyncMode.None; + var insideTaskBlock = method.AsyncMode == AsyncMode.AsyncTask; switch (Mode) { diff --git a/src/JasperFx/CodeGeneration/Frames/MethodCall.cs b/src/JasperFx/CodeGeneration/Frames/MethodCall.cs index 17757521..189f31c1 100644 --- a/src/JasperFx/CodeGeneration/Frames/MethodCall.cs +++ b/src/JasperFx/CodeGeneration/Frames/MethodCall.cs @@ -182,6 +182,12 @@ public void AssignResultTo(Variable variable) { ReturnVariable = variable; ReturnAction = ReturnAction.Assign; + + // F#: reassigning a binding requires it to have been declared `let mutable`. Marking the + // variable here lets its first binding render `let mutable` and this site render `x <- ...`. + // No effect on the C# path, and no effect for arguments/injected fields (which are never + // rendered through FSharpAssignmentUsage). + variable.Mutable = true; } public static MethodCall For(Expression> expression) @@ -438,12 +444,15 @@ private string fsharpDetermineTarget() private string fsharpReturnActionCode(GeneratedMethod method) { - var insideTaskBlock = method.AsyncMode != AsyncMode.None; + // A `task { }` computation expression is emitted only for AsyncMode.AsyncTask; everything + // else (None, ReturnFromLastNode) is a bare F# expression body. + var insideTaskBlock = method.AsyncMode == AsyncMode.AsyncTask; - // The last async node returns the awaited result directly: F# `return!`. + // The last async node IS the method's return value, emitted directly as the trailing Task + // expression (no task block, no `return!`). F# returns the Task without a state machine. if (IsAsync && method.AsyncMode == AsyncMode.ReturnFromLastNode) { - return "return! "; + return string.Empty; } if (ReturnVariable == null) diff --git a/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs b/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs index 8770650f..9c7d1f8b 100644 --- a/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/ReturnFrame.cs @@ -35,7 +35,7 @@ public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter wr // trailing expression (no `return`), while inside a `task { }` computation // expression you DO use `return`. `unit` is written as `()`. var expression = ReturnedVariable == null ? "()" : ReturnedVariable.Usage; - var insideTaskBlock = method.AsyncMode != AsyncMode.None; + var insideTaskBlock = method.AsyncMode == AsyncMode.AsyncTask; writer.Write(insideTaskBlock ? $"return {expression}" : expression); } diff --git a/src/JasperFx/CodeGeneration/GeneratedMethod.cs b/src/JasperFx/CodeGeneration/GeneratedMethod.cs index daeb6d95..37042fe9 100644 --- a/src/JasperFx/CodeGeneration/GeneratedMethod.cs +++ b/src/JasperFx/CodeGeneration/GeneratedMethod.cs @@ -213,11 +213,7 @@ public void WriteFSharpMethod(ISourceWriter writer) // fields are reachable directly by name regardless of the self identifier. writer.Write($"BLOCK:{keyword} _.{MethodName}({arguments}) : {returnType} ="); - if (AsyncMode == AsyncMode.None) - { - _top.GenerateFSharpCode(this, writer); - } - else + if (AsyncMode == AsyncMode.AsyncTask) { // The `task { }` braces are genuine F# syntax, so they are written literally // (with manual indentation) rather than through the brace-free block protocol. @@ -227,6 +223,12 @@ public void WriteFSharpMethod(ISourceWriter writer) writer.IndentionLevel--; writer.WriteLine("}"); } + else + { + // Synchronous (None) OR a single trailing Task expression (ReturnFromLastNode): both are + // a bare F# expression body — no state machine. The frames emit the trailing expression. + _top.GenerateFSharpCode(this, writer); + } writer.FinishBlock(); } From 37cbb1e560eeae58c233694f1953d68fcc51bf1b Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:52:16 -0500 Subject: [PATCH 4/7] F# emit for control-flow frames: if / if-else null guard / try-finally (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 finally `. 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) --- .../FSharpCodegenSample.cs | 72 +++++++++ src/CodegenTests.FSharpFixture/Generated.fs | 29 ++++ src/CodegenTests/FSharpControlFlowTests.cs | 146 ++++++++++++++++++ src/FSharpCodegenTarget/Contracts.cs | 42 +++++ .../CodeGeneration/Frames/CompositeFrame.cs | 27 ++++ src/JasperFx/CodeGeneration/Frames/IfBlock.cs | 11 ++ .../Frames/IfElseNullGuardFrame.cs | 23 +++ .../Frames/TryFinallyWrappedFrame.cs | 25 +++ 8 files changed, 375 insertions(+) create mode 100644 src/CodegenTests/FSharpControlFlowTests.cs diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index 04b91d14..ddb88d44 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -51,10 +51,82 @@ public static GeneratedAssembly BuildSampleAssembly() AddAsyncType(assembly); AddDirectAsyncType(assembly); AddAccumulatorType(assembly); + AddConditionalType(assembly); + AddToggleType(assembly); + AddResourceType(assembly); return assembly; } + /// + /// A null-guard that returns one of two values — F# if isNull x then ... else ... + /// as the trailing (return) expression (IfElseNullGuardFrame). + /// + private static void AddConditionalType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedConditionalGreeter", typeof(IConditionalGreeter)); + var method = type.MethodFor(nameof(IConditionalGreeter.Describe)); + var input = method.Arguments[0]; + + var service = new InjectedField(typeof(ControlFlowService), "controlFlowService"); + + var fallback = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Fallback)) + { + Target = service, ReturnAction = ReturnAction.Return + }; + var echo = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Echo)) + { + Target = service, ReturnAction = ReturnAction.Return + }; + echo.Arguments[0] = input; + + method.Frames.Add(new IfElseNullGuardFrame(input, new Frame[] { fallback }, new Frame[] { echo })); + } + + /// + /// A conditional side effect — F# if flag then ... (IfBlock, a CompositeFrame). + /// + private static void AddToggleType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedToggle", typeof(IToggle)); + var method = type.MethodFor(nameof(IToggle.Toggle)); + var flag = method.Arguments[0]; + + var service = new InjectedField(typeof(ControlFlowService), "controlFlowService"); + var record = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Record)) + { + Target = service + }; + + method.Frames.Add(new IfBlock(flag, record)); + } + + /// + /// A try/finally — F# try ... finally ... (TryFinallyWrapperFrame). + /// + private static void AddResourceType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedResourceRunner", typeof(IResource)); + var method = type.MethodFor(nameof(IResource.Run)); + + var service = new InjectedField(typeof(ControlFlowService), "controlFlowService"); + var begin = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Begin)) + { + Target = service + }; + var work = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Record)) + { + Target = service + }; + var end = new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.End)) + { + Target = service + }; + + method.Frames.Add(new TryFinallyWrapperFrame(begin, new Frame[] { end })); + method.Frames.Add(work); + } + /// /// A second type whose method is asynchronous, so the body is wrapped in a /// task { } computation expression with let! (await) and return. diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index 59edff75..7acc215d 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -45,3 +45,32 @@ type GeneratedAccumulator(accumulatorService: FSharpCodegenTarget.AccumulatorSer mutableBox <- _accumulatorService.Advance(mutableBox) mutableBox +type GeneratedConditionalGreeter(controlFlowService: FSharpCodegenTarget.ControlFlowService) = + let _controlFlowService = controlFlowService + + interface FSharpCodegenTarget.IConditionalGreeter with + member _.Describe(input: string) : string = + if isNull input then + _controlFlowService.Fallback() + else + _controlFlowService.Echo(input) + +type GeneratedToggle(controlFlowService: FSharpCodegenTarget.ControlFlowService) = + let _controlFlowService = controlFlowService + + interface FSharpCodegenTarget.IToggle with + member _.Toggle(flag: bool) : unit = + if flag then + _controlFlowService.Record() + +type GeneratedResourceRunner(controlFlowService: FSharpCodegenTarget.ControlFlowService) = + let _controlFlowService = controlFlowService + + interface FSharpCodegenTarget.IResource with + member _.Run() : unit = + _controlFlowService.Begin() + try + _controlFlowService.Record() + finally + _controlFlowService.End() + diff --git a/src/CodegenTests/FSharpControlFlowTests.cs b/src/CodegenTests/FSharpControlFlowTests.cs new file mode 100644 index 00000000..5b7c2f92 --- /dev/null +++ b/src/CodegenTests/FSharpControlFlowTests.cs @@ -0,0 +1,146 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Shouldly; +using Xunit.Abstractions; + +namespace CodegenTests; + +public interface IFSharpConditional +{ + string Describe(string input); +} + +public interface IFSharpToggle +{ + void Toggle(bool flag); +} + +public interface IFSharpResource +{ + void Run(); +} + +public class FSharpControlService +{ + public string Fallback() + { + return "fallback"; + } + + public string Echo(string input) + { + return input; + } + + public void Record() + { + } + + public void Begin() + { + } + + public void End() + { + } +} + +public class FSharpControlFlowTests +{ + private readonly ITestOutputHelper _output; + + public FSharpControlFlowTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void if_else_null_guard_emits_an_fsharp_if_then_else_expression() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedConditional", typeof(IFSharpConditional)); + var method = type.MethodFor(nameof(IFSharpConditional.Describe)); + var input = method.Arguments[0]; + + var service = new InjectedField(typeof(FSharpControlService), "service"); + + var fallback = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Fallback)) + { + Target = service, ReturnAction = ReturnAction.Return + }; + var echo = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Echo)) + { + Target = service, ReturnAction = ReturnAction.Return + }; + echo.Arguments[0] = input; + + method.Frames.Add(new IfElseNullGuardFrame(input, new Frame[] { fallback }, new Frame[] { echo })); + + var code = assembly.GenerateFSharpCode(); + _output.WriteLine(code); + + code.ShouldContain("if isNull input then"); + code.ShouldContain("_service.Fallback()"); + code.ShouldContain("else"); + code.ShouldContain("_service.Echo(input)"); + } + + [Fact] + public void if_block_emits_an_fsharp_if_then() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedToggle", typeof(IFSharpToggle)); + var method = type.MethodFor(nameof(IFSharpToggle.Toggle)); + var flag = method.Arguments[0]; + + var service = new InjectedField(typeof(FSharpControlService), "service"); + var record = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Record)) + { + Target = service + }; + + method.Frames.Add(new IfBlock(flag, record)); + + var code = assembly.GenerateFSharpCode(); + _output.WriteLine(code); + + code.ShouldContain("if flag then"); + code.ShouldContain("_service.Record()"); + code.ShouldNotContain("{"); + } + + [Fact] + public void try_finally_emits_an_fsharp_try_finally_expression() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedResource", typeof(IFSharpResource)); + var method = type.MethodFor(nameof(IFSharpResource.Run)); + + var service = new InjectedField(typeof(FSharpControlService), "service"); + var begin = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Begin)) + { + Target = service + }; + var work = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Record)) + { + Target = service + }; + var end = new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.End)) + { + Target = service + }; + + method.Frames.Add(new TryFinallyWrapperFrame(begin, new Frame[] { end })); + method.Frames.Add(work); + + var code = assembly.GenerateFSharpCode(); + _output.WriteLine(code); + + code.ShouldContain("try"); + code.ShouldContain("finally"); + code.ShouldContain("_service.Begin()"); + code.ShouldContain("_service.Record()"); + code.ShouldContain("_service.End()"); + } +} diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index 1bec2b1c..70c39037 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -50,6 +50,48 @@ public MutableBox Advance(MutableBox box) } } +// Control-flow contracts: exercise IfElseNullGuardFrame, IfBlock, and TryFinallyWrapperFrame. + +public interface IConditionalGreeter +{ + string Describe(string input); +} + +public interface IToggle +{ + void Toggle(bool flag); +} + +public interface IResource +{ + void Run(); +} + +public class ControlFlowService +{ + public string Fallback() + { + return "fallback"; + } + + public string Echo(string input) + { + return input; + } + + public void Record() + { + } + + public void Begin() + { + } + + public void End() + { + } +} + /// /// A simple value object the generated method constructs (exercises ConstructorFrame). /// diff --git a/src/JasperFx/CodeGeneration/Frames/CompositeFrame.cs b/src/JasperFx/CodeGeneration/Frames/CompositeFrame.cs index 1e695e21..193ba60f 100644 --- a/src/JasperFx/CodeGeneration/Frames/CompositeFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/CompositeFrame.cs @@ -1,4 +1,5 @@ using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; namespace JasperFx.CodeGeneration.Frames; @@ -28,8 +29,34 @@ public sealed override void GenerateCode(GeneratedMethod method, ISourceWriter w Next?.GenerateCode(method, writer); } + public sealed override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + if (_inner.Length > 1) + { + for (var i = 1; i < _inner.Length; i++) + { + _inner[i - 1].Next = _inner[i]; + } + } + + generateFSharpCode(method, writer, _inner[0]); + + Next?.GenerateFSharpCode(method, writer); + } + protected abstract void generateCode(GeneratedMethod method, ISourceWriter writer, Frame inner); + /// + /// EXPERIMENTAL F# counterpart to . Default-throws (naming the + /// frame) so composite frames that have not opted in keep failing loudly rather than + /// emitting invalid F#. + /// + protected virtual void generateFSharpCode(GeneratedMethod method, ISourceWriter writer, Frame inner) + { + throw new NotSupportedException( + $"F# code generation is not supported for the frame '{GetType().FullNameInCode()}'."); + } + public override IEnumerable FindVariables(IMethodVariables chain) { return _inner.SelectMany(x => x.FindVariables(chain)).Distinct(); diff --git a/src/JasperFx/CodeGeneration/Frames/IfBlock.cs b/src/JasperFx/CodeGeneration/Frames/IfBlock.cs index 19299eac..372730eb 100644 --- a/src/JasperFx/CodeGeneration/Frames/IfBlock.cs +++ b/src/JasperFx/CodeGeneration/Frames/IfBlock.cs @@ -21,4 +21,15 @@ protected override void generateCode(GeneratedMethod method, ISourceWriter write inner.GenerateCode(method, writer); writer.FinishBlock(); } + + protected override void generateFSharpCode(GeneratedMethod method, ISourceWriter writer, Frame inner) + { + // F#: `if then` with an indented (brace-free) body. The Condition string is a + // raw passthrough (like CodeFrame), so the caller supplies F#-valid text; an IfBlock built + // from a Variable uses the bare identifier, which is already valid. The inner must be a + // unit-typed expression (a side effect) when the if-block is not the trailing expression. + writer.Write($"BLOCK:if {Condition} then"); + inner.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + } } \ No newline at end of file diff --git a/src/JasperFx/CodeGeneration/Frames/IfElseNullGuardFrame.cs b/src/JasperFx/CodeGeneration/Frames/IfElseNullGuardFrame.cs index 0119656a..8a1fe06a 100644 --- a/src/JasperFx/CodeGeneration/Frames/IfElseNullGuardFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/IfElseNullGuardFrame.cs @@ -40,6 +40,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // F# if/then/else is an expression: when it is the trailing position both branches yield + // the method's return value; otherwise both branches must be unit. `else` dedents back to + // align with `if` (no braces). + writer.Write($"BLOCK:if isNull {_subject.Usage} then"); + foreach (var frame in _nullPath) frame.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + + writer.Write("BLOCK:else"); + foreach (var frame in _existsPath) frame.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + + Next?.GenerateFSharpCode(method, writer); + } + public override IEnumerable FindVariables(IMethodVariables chain) { foreach (var frame in _existsPath) @@ -94,6 +110,13 @@ protected override void generateCode(GeneratedMethod method, ISourceWriter write inner.GenerateCode(method, writer); writer.FinishBlock(); } + + protected override void generateFSharpCode(GeneratedMethod method, ISourceWriter writer, Frame inner) + { + writer.Write($"BLOCK:if not (isNull {_variable.Usage}) then"); + inner.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + } } } \ No newline at end of file diff --git a/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs b/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs index 79244ebe..096deb28 100644 --- a/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs +++ b/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs @@ -40,6 +40,31 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) writer.FinishBlock(); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // F# try/finally is an expression: `try finally `. The body (Next) yields + // the value; the finally block is unit. Emitted brace-free via the block protocol. + _inner.GenerateFSharpCode(method, writer); + writer.Write("BLOCK:try"); + + Next?.GenerateFSharpCode(method, writer); + + writer.FinishBlock(); + writer.Write("BLOCK:finally"); + + if (_finallys.Length > 1) + { + for (var i = 1; i < _finallys.Length; i++) + { + _finallys[i - 1].Next = _finallys[i]; + } + } + + _finallys[0].GenerateFSharpCode(method, writer); + + writer.FinishBlock(); + } + public override IEnumerable FindVariables(IMethodVariables chain) { foreach (var variable in _inner.FindVariables(chain)) yield return variable; From 686065cebcdc0659b9317a00352623dff3be7e4f Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:55:23 -0500 Subject: [PATCH 5/7] Broaden F# `open` coverage beyond injected-field namespaces (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/CodegenTests.FSharpFixture/Generated.fs | 2 + src/CodegenTests/FSharpGenerationTests.cs | 23 +++++++ .../CodeGeneration/GeneratedAssembly.cs | 60 ++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index 7acc215d..9198da12 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -3,6 +3,8 @@ namespace FSharpCodegenTarget.Generated open FSharpCodegenTarget +open System +open System.Threading.Tasks type GeneratedGreeter(greetingService: FSharpCodegenTarget.GreetingService) = let _greetingService = greetingService diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs index 43b8f2f8..29d8bb72 100644 --- a/src/CodegenTests/FSharpGenerationTests.cs +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -134,6 +134,29 @@ public void emits_a_bare_trailing_task_expression_for_return_from_last_node() code.ShouldContain("_service.CreateGreetingAsync(name)"); } + [Fact] + public void open_statements_cover_return_and_dependency_namespaces() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedAsyncGreeter", typeof(IFSharpAsyncGreeter)); + var method = type.MethodFor(nameof(IFSharpAsyncGreeter.GreetAsync)); + + var service = new InjectedField(typeof(FSharpGreetingService), "service"); + var call = new MethodCall(typeof(FSharpGreetingService), nameof(FSharpGreetingService.CreateGreetingAsync)) + { + Target = service + }; + method.Frames.Add(call); + method.Frames.Add(new ReturnFrame(call.ReturnVariable!)); + + var code = assembly.GenerateFSharpCode(); + + // From the Task return type (not just the injected-field namespace)... + code.ShouldContain("open System.Threading.Tasks"); + // ...and from the injected service + implemented interface. + code.ShouldContain("open CodegenTests"); + } + [Fact] public void renders_let_mutable_and_reassignment_for_return_action_assign() { diff --git a/src/JasperFx/CodeGeneration/GeneratedAssembly.cs b/src/JasperFx/CodeGeneration/GeneratedAssembly.cs index 7bc9a00a..a2bafc45 100644 --- a/src/JasperFx/CodeGeneration/GeneratedAssembly.cs +++ b/src/JasperFx/CodeGeneration/GeneratedAssembly.cs @@ -150,7 +150,7 @@ public string GenerateFSharpCode(IServiceVariableSource? services = null) generatedType.ArrangeFrames(services); } - var namespaces = AllReferencedNamespaces(); + var namespaces = AllReferencedFSharpNamespaces(); using var writer = new FSharp.FSharpSourceWriter(); writer.WriteLine("// "); @@ -173,6 +173,64 @@ public string GenerateFSharpCode(IServiceVariableSource? services = null) return writer.Code(); } + /// + /// EXPERIMENTAL. The namespaces emitted as F# open statements. Extends + /// (injected-field + explicit namespaces) with the + /// namespaces of each generated type's base type, implemented interfaces, and every + /// generated method's return type and argument types (including generic arguments). All + /// emitted F# names are fully qualified, so these opens are convenience rather than a + /// correctness requirement. + /// + public List AllReferencedFSharpNamespaces() + { + var namespaces = new List(AllReferencedNamespaces()); + + foreach (var type in GeneratedTypes) + { + if (type.BaseType != null) + { + namespaces.AddRange(namespacesWithin(type.BaseType)); + } + + foreach (var @interface in type.Interfaces) + { + namespaces.AddRange(namespacesWithin(@interface)); + } + + foreach (var method in type.Methods) + { + namespaces.AddRange(namespacesWithin(method.ReturnType)); + foreach (var argument in method.Arguments) + { + namespaces.AddRange(namespacesWithin(argument.VariableType)); + } + } + } + + return namespaces.Where(x => x.IsNotEmpty()).Distinct().ToList()!; + } + + private static IEnumerable namespacesWithin(Type type) + { + if (type.IsArray) + { + foreach (var ns in namespacesWithin(type.GetElementType()!)) yield return ns; + yield break; + } + + if (type.Namespace.IsNotEmpty()) + { + yield return type.Namespace!; + } + + if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments()) + foreach (var ns in namespacesWithin(argument)) + yield return ns; + } + } + public List AllReferencedNamespaces() { var namespaces = GeneratedTypes From 59b95e9fb306970e45c08c7bc917d6833b3b4033 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 10:57:53 -0500 Subject: [PATCH 6/7] Add a Wolverine-shaped async handler to the F# fixture (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 = 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) --- .../FSharpCodegenSample.cs | 38 +++++++++ src/CodegenTests.FSharpFixture/Generated.fs | 14 ++++ src/CodegenTests/FSharpGenerationTests.cs | 79 +++++++++++++++++++ src/FSharpCodegenTarget/Contracts.cs | 53 +++++++++++++ 4 files changed, 184 insertions(+) diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index ddb88d44..74af2c7b 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -54,10 +54,48 @@ public static GeneratedAssembly BuildSampleAssembly() AddConditionalType(assembly); AddToggleType(assembly); AddResourceType(assembly); + AddOrderHandlerType(assembly); return assembly; } + /// + /// A Wolverine-shaped async handler: construct a domain object from the command, do! + /// an async repository save, build a confirmation with a sync factory call, and return it. + /// Exercises mixed sync/async frames inside a single task { } body. + /// + private static void AddOrderHandlerType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedOrderHandler", typeof(IOrderHandler)); + var method = type.MethodFor(nameof(IOrderHandler.Handle)); + var command = method.Arguments[0]; + + method.Frames.Add(new CommentFrame("Handle a PlaceOrder command (jasperfx#383)")); + + var orderCtor = typeof(Order).GetConstructors().Single(); + var ctorFrame = new ConstructorFrame(typeof(Order), orderCtor); + ctorFrame.Parameters[0] = command; + method.Frames.Add(ctorFrame); + + var repository = new InjectedField(typeof(IOrderRepository), "orderRepository"); + var save = new MethodCall(typeof(IOrderRepository), nameof(IOrderRepository.SaveAsync)) + { + Target = repository + }; + save.Arguments[0] = ctorFrame.Variable; + method.Frames.Add(save); + + var factory = new InjectedField(typeof(ConfirmationFactory), "confirmationFactory"); + var create = new MethodCall(typeof(ConfirmationFactory), nameof(ConfirmationFactory.Create)) + { + Target = factory + }; + create.Arguments[0] = ctorFrame.Variable; + method.Frames.Add(create); + + method.Frames.Add(new ReturnFrame(create.ReturnVariable!)); + } + /// /// A null-guard that returns one of two values — F# if isNull x then ... else ... /// as the trailing (return) expression (IfElseNullGuardFrame). diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index 9198da12..64c94952 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -76,3 +76,17 @@ type GeneratedResourceRunner(controlFlowService: FSharpCodegenTarget.ControlFlow finally _controlFlowService.End() +type GeneratedOrderHandler(confirmationFactory: FSharpCodegenTarget.ConfirmationFactory, orderRepository: FSharpCodegenTarget.IOrderRepository) = + let _confirmationFactory = confirmationFactory + let _orderRepository = orderRepository + + interface FSharpCodegenTarget.IOrderHandler with + member _.Handle(command: FSharpCodegenTarget.PlaceOrder) : System.Threading.Tasks.Task = + task { + // Handle a PlaceOrder command (jasperfx#383) + let order = FSharpCodegenTarget.Order(command) + do! _orderRepository.SaveAsync(order) + let orderConfirmation = _confirmationFactory.Create(order) + return orderConfirmation + } + diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs index 29d8bb72..40d92d9b 100644 --- a/src/CodegenTests/FSharpGenerationTests.cs +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -48,6 +48,42 @@ public FSharpBox Advance(FSharpBox box) } } +public class FSharpOrderCommand +{ +} + +public class FSharpOrder +{ + public FSharpOrder(FSharpOrderCommand command) + { + } +} + +public class FSharpOrderResult +{ + public FSharpOrderResult(FSharpOrder order) + { + } +} + +public interface IFSharpOrderRepository +{ + Task SaveAsync(FSharpOrder order); +} + +public class FSharpOrderResultFactory +{ + public FSharpOrderResult Create(FSharpOrder order) + { + return new FSharpOrderResult(order); + } +} + +public interface IFSharpOrderHandler +{ + Task Handle(FSharpOrderCommand command); +} + public class FSharpGenerationTests { [Fact] @@ -185,6 +221,49 @@ public void renders_let_mutable_and_reassignment_for_return_action_assign() code.ShouldContain($"{usage} <- _service.Advance({usage})"); } + [Fact] + public void emits_a_wolverine_shaped_async_handler_with_mixed_sync_and_async_calls() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedOrderHandler", typeof(IFSharpOrderHandler)); + var method = type.MethodFor(nameof(IFSharpOrderHandler.Handle)); + var command = method.Arguments[0]; + + var orderCtor = typeof(FSharpOrder).GetConstructors().Single(); + var ctorFrame = new ConstructorFrame(typeof(FSharpOrder), orderCtor); + ctorFrame.Parameters[0] = command; + method.Frames.Add(ctorFrame); + + var repository = new InjectedField(typeof(IFSharpOrderRepository), "repository"); + var save = new MethodCall(typeof(IFSharpOrderRepository), nameof(IFSharpOrderRepository.SaveAsync)) + { + Target = repository + }; + save.Arguments[0] = ctorFrame.Variable; + method.Frames.Add(save); + + var factory = new InjectedField(typeof(FSharpOrderResultFactory), "factory"); + var create = new MethodCall(typeof(FSharpOrderResultFactory), nameof(FSharpOrderResultFactory.Create)) + { + Target = factory + }; + create.Arguments[0] = ctorFrame.Variable; + method.Frames.Add(create); + + method.Frames.Add(new ReturnFrame(create.ReturnVariable!)); + + var code = assembly.GenerateFSharpCode(); + + var order = ctorFrame.Variable.Usage; + var result = create.ReturnVariable!.Usage; + + code.ShouldContain("task {"); + code.ShouldContain($"let {order} = CodegenTests.FSharpOrder(command)"); + code.ShouldContain($"do! _repository.SaveAsync({order})"); // void async -> do! + code.ShouldContain($"let {result} = _factory.Create({order})"); // sync call inside task + code.ShouldContain($"return {result}"); + } + [Fact] public void unimplemented_frame_throws_a_NotSupportedException_naming_itself() { diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index 70c39037..7fdb5197 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -92,6 +92,59 @@ public void End() } } +// A Wolverine-shaped message handler: construct a domain object, await a repository +// save, build a confirmation, return it. Exercises a realistic multi-frame async body. + +public class PlaceOrder +{ + public PlaceOrder(string productId, int quantity) + { + ProductId = productId; + Quantity = quantity; + } + + public string ProductId { get; } + public int Quantity { get; } +} + +public class Order +{ + public Order(PlaceOrder command) + { + ProductId = command.ProductId; + } + + public string ProductId { get; } +} + +public class OrderConfirmation +{ + public OrderConfirmation(Order order) + { + ProductId = order.ProductId; + } + + public string ProductId { get; } +} + +public interface IOrderRepository +{ + Task SaveAsync(Order order); +} + +public class ConfirmationFactory +{ + public OrderConfirmation Create(Order order) + { + return new OrderConfirmation(order); + } +} + +public interface IOrderHandler +{ + Task Handle(PlaceOrder command); +} + /// /// A simple value object the generated method constructs (exercises ConstructorFrame). /// From 73ce6f2b86235db380b4fa8dd30706e7b528510c Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Wed, 27 May 2026 11:11:07 -0500 Subject: [PATCH 7/7] F# emit for synchronous Task-returning methods + harden the compile gate (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../FSharpCodegenSample.cs | 17 ++++++++++++ .../FSharpCompilationGate.cs | 9 +++++++ src/CodegenTests.FSharpFixture/Generated.fs | 8 ++++++ src/CodegenTests/FSharpGenerationTests.cs | 27 +++++++++++++++++++ src/FSharpCodegenTarget/Contracts.cs | 9 +++++++ .../CodeGeneration/GeneratedMethod.cs | 9 +++++++ 6 files changed, 79 insertions(+) diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index 74af2c7b..294142a6 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -55,10 +55,27 @@ public static GeneratedAssembly BuildSampleAssembly() AddToggleType(assembly); AddResourceType(assembly); AddOrderHandlerType(assembly); + AddSyncTaskHandlerType(assembly); return assembly; } + /// + /// A synchronous body behind a non-generic Task signature: the side effect runs, then + /// the method yields Task.CompletedTask (AsyncMode.None + Task return). + /// + private static void AddSyncTaskHandlerType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedSyncTaskHandler", typeof(ISyncTaskHandler)); + var method = type.MethodFor(nameof(ISyncTaskHandler.HandleAsync)); + + var service = new InjectedField(typeof(ControlFlowService), "controlFlowService"); + method.Frames.Add(new MethodCall(typeof(ControlFlowService), nameof(ControlFlowService.Record)) + { + Target = service + }); + } + /// /// A Wolverine-shaped async handler: construct a domain object from the command, do! /// an async repository save, build a confirmation with a sync factory call, and return it. diff --git a/src/CodegenTests.FSharp/FSharpCompilationGate.cs b/src/CodegenTests.FSharp/FSharpCompilationGate.cs index 7e36f79c..35af2488 100644 --- a/src/CodegenTests.FSharp/FSharpCompilationGate.cs +++ b/src/CodegenTests.FSharp/FSharpCompilationGate.cs @@ -34,6 +34,15 @@ public void generated_fsharp_compiles_via_dotnet_build() var fixtureProject = FSharpCodegenSample.FixtureProjectPath(); var (exitCode, output) = RunDotnet($"build \"{fixtureProject}\" -c Debug --nologo"); + // A nested `dotnet build` (build-inside-test) can occasionally trip an internal F# compiler + // crash (e.g. FS0193 in the auto-generated AssemblyAttributes.fs) that has nothing to do with + // the generated source. Retry once on that specific signature only — a genuine F# error in + // Generated.fs is deterministic and would persist across the retry, so this can't mask it. + if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error"))) + { + (exitCode, output) = RunDotnet($"build \"{fixtureProject}\" -c Debug --nologo"); + } + _output.WriteLine(output); exitCode.ShouldBe(0); } diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index 64c94952..a2fb0180 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -90,3 +90,11 @@ type GeneratedOrderHandler(confirmationFactory: FSharpCodegenTarget.Confirmation return orderConfirmation } +type GeneratedSyncTaskHandler(controlFlowService: FSharpCodegenTarget.ControlFlowService) = + let _controlFlowService = controlFlowService + + interface FSharpCodegenTarget.ISyncTaskHandler with + member _.HandleAsync(name: string) : System.Threading.Tasks.Task = + _controlFlowService.Record() + System.Threading.Tasks.Task.CompletedTask + diff --git a/src/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs index 40d92d9b..7e17a107 100644 --- a/src/CodegenTests/FSharpGenerationTests.cs +++ b/src/CodegenTests/FSharpGenerationTests.cs @@ -84,6 +84,11 @@ public interface IFSharpOrderHandler Task Handle(FSharpOrderCommand command); } +public interface IFSharpSyncTaskHandler +{ + Task HandleAsync(string name); +} + public class FSharpGenerationTests { [Fact] @@ -264,6 +269,28 @@ public void emits_a_wolverine_shaped_async_handler_with_mixed_sync_and_async_cal code.ShouldContain($"return {result}"); } + [Fact] + public void emits_task_completed_task_for_a_synchronous_task_returning_method() + { + var assembly = new GeneratedAssembly(new GenerationRules("Some.Generated")); + var type = assembly.AddType("GeneratedSyncTaskHandler", typeof(IFSharpSyncTaskHandler)); + var method = type.MethodFor(nameof(IFSharpSyncTaskHandler.HandleAsync)); + + var service = new InjectedField(typeof(FSharpControlService), "service"); + method.Frames.Add(new MethodCall(typeof(FSharpControlService), nameof(FSharpControlService.Record)) + { + Target = service + }); + + var code = assembly.GenerateFSharpCode(); + + code.ShouldContain("member _.HandleAsync(name: string) : System.Threading.Tasks.Task ="); + code.ShouldContain("_service.Record()"); + // No state machine for a synchronous body — just yield a completed Task. + code.ShouldNotContain("task {"); + code.ShouldContain("System.Threading.Tasks.Task.CompletedTask"); + } + [Fact] public void unimplemented_frame_throws_a_NotSupportedException_naming_itself() { diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index 7fdb5197..03de856e 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -67,6 +67,15 @@ public interface IResource void Run(); } +/// +/// A handler whose signature returns a (non-generic) Task but whose body is synchronous — +/// exercises the AsyncMode.None + Task return path that must emit Task.CompletedTask. +/// +public interface ISyncTaskHandler +{ + Task HandleAsync(string name); +} + public class ControlFlowService { public string Fallback() diff --git a/src/JasperFx/CodeGeneration/GeneratedMethod.cs b/src/JasperFx/CodeGeneration/GeneratedMethod.cs index 37042fe9..58bb572f 100644 --- a/src/JasperFx/CodeGeneration/GeneratedMethod.cs +++ b/src/JasperFx/CodeGeneration/GeneratedMethod.cs @@ -228,6 +228,15 @@ public void WriteFSharpMethod(ISourceWriter writer) // Synchronous (None) OR a single trailing Task expression (ReturnFromLastNode): both are // a bare F# expression body — no state machine. The frames emit the trailing expression. _top.GenerateFSharpCode(this, writer); + + // A synchronous method whose signature returns a non-generic Task must still yield a + // Task. C# appends `return Task.CompletedTask;`; the F# equivalent is a trailing + // `Task.CompletedTask` expression after the (unit) side-effect frames. + if (AsyncMode != AsyncMode.ReturnFromLastNode && ReturnVariable == null && + ReturnType == typeof(Task)) + { + writer.Write($"{typeof(Task).FullNameInCode()}.CompletedTask"); + } } writer.FinishBlock();