diff --git a/jasperfx.sln b/jasperfx.sln
index 7109d0e..e152bf5 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 0000000..5c780e5
--- /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 0000000..294142a
--- /dev/null
+++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs
@@ -0,0 +1,283 @@
+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));
+
+ AddAsyncType(assembly);
+ AddDirectAsyncType(assembly);
+ AddAccumulatorType(assembly);
+ AddConditionalType(assembly);
+ 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.
+ /// 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).
+ ///
+ 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.
+ ///
+ 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!));
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 0000000..35af248
--- /dev/null
+++ b/src/CodegenTests.FSharp/FSharpCompilationGate.cs
@@ -0,0 +1,72 @@
+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");
+
+ // 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);
+ }
+
+ 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 0000000..344f535
--- /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 0000000..9eab387
--- /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 0000000..a2fb018
--- /dev/null
+++ b/src/CodegenTests.FSharpFixture/Generated.fs
@@ -0,0 +1,100 @@
+//
+
+namespace FSharpCodegenTarget.Generated
+
+open FSharpCodegenTarget
+open System
+open System.Threading.Tasks
+
+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
+
+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
+ }
+
+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
+
+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()
+
+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
+ }
+
+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/FSharpControlFlowTests.cs b/src/CodegenTests/FSharpControlFlowTests.cs
new file mode 100644
index 0000000..5b7c2f9
--- /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/CodegenTests/FSharpGenerationTests.cs b/src/CodegenTests/FSharpGenerationTests.cs
new file mode 100644
index 0000000..7e17a10
--- /dev/null
+++ b/src/CodegenTests/FSharpGenerationTests.cs
@@ -0,0 +1,312 @@
+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 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 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 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 interface IFSharpSyncTaskHandler
+{
+ Task HandleAsync(string 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 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 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 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()
+ {
+ 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 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 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()
+ {
+ 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 0000000..b0278fe
--- /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 0000000..5b1dc8d
--- /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 0000000..03de856
--- /dev/null
+++ b/src/FSharpCodegenTarget/Contracts.cs
@@ -0,0 +1,185 @@
+namespace FSharpCodegenTarget;
+
+///
+/// The interface the generated F# type implements — stands in for a Wolverine
+/// handler/endpoint contract.
+///
+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);
+}
+
+///
+/// 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;
+ }
+}
+
+// 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();
+}
+
+///
+/// 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()
+ {
+ return "fallback";
+ }
+
+ public string Echo(string input)
+ {
+ return input;
+ }
+
+ public void Record()
+ {
+ }
+
+ public void Begin()
+ {
+ }
+
+ 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).
+///
+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 + "!";
+ }
+
+ public Task CreateGreetingAsync(Salutation salutation)
+ {
+ return Task.FromResult(salutation.Text + "!");
+ }
+}
diff --git a/src/FSharpCodegenTarget/FSharpCodegenTarget.csproj b/src/FSharpCodegenTarget/FSharpCodegenTarget.csproj
new file mode 100644
index 0000000..defa6bb
--- /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 0000000..07ece21
--- /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 c1020e5..5516637 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/CompositeFrame.cs b/src/JasperFx/CodeGeneration/Frames/CompositeFrame.cs
index 1e695e2..193ba60 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/ConstructorFrame.cs b/src/JasperFx/CodeGeneration/Frames/ConstructorFrame.cs
index 6207167..c081587 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.AsyncTask;
+
+ 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 52d0499..6fc8597 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/IfBlock.cs b/src/JasperFx/CodeGeneration/Frames/IfBlock.cs
index 19299ea..372730e 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 0119656..8a1fe06 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/MethodCall.cs b/src/JasperFx/CodeGeneration/Frames/MethodCall.cs
index c4727fb..189f31c 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)
@@ -388,6 +394,97 @@ 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)
+ {
+ // 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 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 string.Empty;
+ }
+
+ 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 09aaf1e..9c7d1f8 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.AsyncTask;
+
+ writer.Write(insideTaskBlock ? $"return {expression}" : expression);
+ }
+
public override IEnumerable FindVariables(IMethodVariables chain)
{
if (ReturnedVariable == null && ReturnType != null)
diff --git a/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs b/src/JasperFx/CodeGeneration/Frames/TryFinallyWrappedFrame.cs
index 79244eb..096deb2 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;
diff --git a/src/JasperFx/CodeGeneration/FramesCollection.cs b/src/JasperFx/CodeGeneration/FramesCollection.cs
index bbe37b4..0251901 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 c655a23..a2bafc4 100644
--- a/src/JasperFx/CodeGeneration/GeneratedAssembly.cs
+++ b/src/JasperFx/CodeGeneration/GeneratedAssembly.cs
@@ -136,6 +136,101 @@ 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 = AllReferencedFSharpNamespaces();
+
+ 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();
+ }
+
+ ///
+ /// 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
diff --git a/src/JasperFx/CodeGeneration/GeneratedMethod.cs b/src/JasperFx/CodeGeneration/GeneratedMethod.cs
index 478782c..58bb572 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,60 @@ 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.AsyncTask)
+ {
+ // 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("}");
+ }
+ 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);
+
+ // 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();
+ }
+
protected void writeReturnStatement(ISourceWriter writer)
{
if (ReturnVariable != null)
diff --git a/src/JasperFx/CodeGeneration/GeneratedType.cs b/src/JasperFx/CodeGeneration/GeneratedType.cs
index a101853..af31e06 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 0e450a4..a21db5d 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 5d18b43..bee7e52 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*
///