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* ///