diff --git a/.github/workflows/fsharp.yml b/.github/workflows/fsharp.yml index 7a6c8d6b4..2d855b941 100644 --- a/.github/workflows/fsharp.yml +++ b/.github/workflows/fsharp.yml @@ -85,6 +85,9 @@ jobs: run: dotnet test src/Testing/Wolverine.Cosmos.FSharpTests/Wolverine.Cosmos.FSharpTests.csproj -c "$config" --nologo # Run-verified: boots a host in TypeLoadMode.Static against the pre-generated F# adapter and - # asserts the handler actually executes (not just compiles). - - name: Behavioural run-step (TypeLoadMode.Static) + # asserts the handler actually executes (not just compiles). Also exercises the + # `dotnet run -- codegen write --language fsharp` CLI end to end against the F# app, asserting it + # exits 0 (every chain — including Wolverine's internal HandlerRegistry — emits F#) and that the + # generated adapter matches the committed, run-verified Generated.fs. + - name: Behavioural run-step + codegen write --language fsharp (TypeLoadMode.Static) run: dotnet test src/Testing/Wolverine.Behavioural.FSharpTests/Wolverine.Behavioural.FSharpTests.csproj -c "$config" --nologo diff --git a/Directory.Build.props b/Directory.Build.props index 136e0c64c..09972ef26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ true true enable - 6.3.0 + 6.3.1 $(PackageProjectUrl) true true diff --git a/Directory.Packages.props b/Directory.Packages.props index 687c59681..fccc662ba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,13 +32,13 @@ - - - + + + - + diff --git a/docs/tutorials/fsharp.md b/docs/tutorials/fsharp.md index 6a49d26a3..75b09a3aa 100644 --- a/docs/tutorials/fsharp.md +++ b/docs/tutorials/fsharp.md @@ -126,12 +126,41 @@ type BehaviouralPingHandler1244766258() = System.Threading.Tasks.Task.CompletedTask ``` +## Generating the F# code from the command line + +Wolverine apps answer the JasperFx command line (the final `RunJasperFxCommands(args)` in your +`Program`). As of JasperFx 2.4.1 the `codegen` command takes a `--language` flag, so you can write the +pre-generated code out as **F#** instead of C#: + +```bash +dotnet run -- codegen write --language fsharp +``` + +This emits one `.fs` file per generated type into your code-generation output directory +(`Internal/Generated/…` by default) — the handler adapters plus Wolverine's static +`GeneratedHandlerRegistry`. Because F# requires explicit, ordered compilation, add the generated files +to your `.fsproj` `` list (the registry and adapters depend on your handler/message types, so +list them after those): + +```xml + + + + + + +``` + +Re-run the command (and commit the regenerated files) whenever your handler graph changes; the generated +type names are deterministic for a given handler graph. + ## Running on pre-generated F# code To ship an F# app that runs on pre-generated code rather than compiling at startup: -1. Generate the F# adapters and commit them into your application (for example as a `Generated.fs` - compiled into the app assembly). The generated type names are deterministic for a given handler graph. +1. Generate the F# adapters with `codegen write --language fsharp` (above) and commit them into your + application, compiled into the app assembly. The generated type names are deterministic for a given + handler graph. 2. Boot the host in `TypeLoadMode.Static` and point Wolverine's `ApplicationAssembly` at the assembly that contains the pre-generated F# — Wolverine then loads each handler adapter **by name** out of that assembly's exported types, with no Roslyn at runtime: diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs new file mode 100644 index 000000000..44bb1a179 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs @@ -0,0 +1,22 @@ +module WolverineBehaviouralFSharpApp.Program + +open Microsoft.Extensions.Hosting +open JasperFx +open Wolverine +open WolverineBehaviouralFSharpApp + +// Entry point so the app answers the JasperFx CLI verbs — notably +// `dotnet run -- codegen write --language fsharp`, which regenerates the F# handler adapter. +// The Wolverine configuration here MUST match BehaviouralCodegen.Configure in the test project +// (DisableConventionalDiscovery + IncludeType) so the generated handler +// type-name hash is identical to the one the behavioural run-step computes under TypeLoadMode.Static. +[] +let main args = + Host + .CreateDefaultBuilder(args) + .UseWolverine(fun opts -> + opts.Discovery.DisableConventionalDiscovery() |> ignore + opts.Discovery.IncludeType() |> ignore) + .RunJasperFxCommands(args) + .GetAwaiter() + .GetResult() diff --git a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj index 62f5ccc0d..53a119acc 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj +++ b/src/Testing/Wolverine.Behavioural.FSharpApp/Wolverine.Behavioural.FSharpApp.fsproj @@ -13,6 +13,11 @@ net9.0 false + + Exe + false disable @@ -25,6 +30,7 @@ + diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs index 6f00247c7..50ae91242 100644 --- a/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/BehaviouralRunStep.cs @@ -16,6 +16,7 @@ namespace Wolverine.Behavioural.FSharpTests; /// carries the committed, pre-generated F# handler adapter), sends a message, and asserts the F# /// handler actually executed — run-verification, not just compilation. /// +[Collection("BehaviouralFSharp")] public class BehaviouralRunStep { private readonly ITestOutputHelper _output; diff --git a/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs new file mode 100644 index 000000000..1e0045613 --- /dev/null +++ b/src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Wolverine.Behavioural.FSharpTests; + +// The behavioural F# tests each spawn nested `dotnet build`/`dotnet run` of the F# app and load its +// assembly; running them concurrently races on the app's build outputs. Serialize them. +[CollectionDefinition("BehaviouralFSharp", DisableParallelization = true)] +public class BehaviouralFSharpCollection; + +/// +/// Proves the JasperFx codegen write --language fsharp CLI flag works end to end against a +/// real Wolverine F# application: it runs the verb against Wolverine.Behavioural.FSharpApp, +/// asserts every handler chain (including Wolverine's internal static HandlerRegistry) emits F# +/// (exit 0), and confirms the generated handler adapter is identical to the committed +/// Generated.fs — which the behavioural run-step already compiles and executes under +/// . So: the CLI produces the same F# +/// that is proven to run. +/// +[Collection("BehaviouralFSharp")] +public class CodegenWriteFSharpCli +{ + private readonly ITestOutputHelper _output; + + public CodegenWriteFSharpCli(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task codegen_write_fsharp_generates_runnable_fsharp_for_a_wolverine_app() + { + var appProject = BehaviouralCodegen.AppProjectPath(); + var appDir = Path.GetDirectoryName(appProject)!; + var generatedDir = Path.Combine(appDir, "Internal"); + + // Start clean so we only see what THIS run produced. + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + + try + { + var (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp"); + + // A transient F# build crash / file lock can fail the embedded `dotnet run` build; retry once. + if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error") + || output.Contains("being used by another process") + || output.Contains("MSB3883"))) + { + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + (exitCode, output) = await RunDotnetAsync(appDir, "run --framework net9.0 -- codegen write --language fsharp"); + } + + _output.WriteLine(output); + + // Exit 0 means every generated handler chain — including Wolverine's internal static + // HandlerRegistry (WriteTypeArrayFrame) — successfully emitted F#. + exitCode.ShouldBe(0); + + var generatedFiles = Directory.GetFiles(generatedDir, "*.fs", SearchOption.AllDirectories); + generatedFiles.ShouldNotBeEmpty(); + + // The handler adapter the CLI produced must match the committed Generated.fs that the + // behavioural run-step compiles + executes under TypeLoadMode.Static. + var adapterFile = generatedFiles.Single(f => + Path.GetFileName(f).StartsWith("BehaviouralPingHandler", StringComparison.Ordinal)); + var generatedAdapter = Normalize(await File.ReadAllTextAsync(adapterFile)); + var committedAdapter = Normalize(await File.ReadAllTextAsync(BehaviouralCodegen.GeneratedFilePath())); + generatedAdapter.ShouldBe(committedAdapter); + + // The static HandlerRegistry was also emitted as valid F# (the Type[] accessors as F# + // array literals) — this is what previously threw NotSupportedException. + var registryFile = generatedFiles.Single(f => + Path.GetFileName(f) == "GeneratedHandlerRegistry.fs"); + var registry = await File.ReadAllTextAsync(registryFile); + registry.ShouldContain("inherit Wolverine.Runtime.Handlers.HandlerRegistry()"); + registry.ShouldContain("typeof"); + } + finally + { + if (Directory.Exists(generatedDir)) Directory.Delete(generatedDir, recursive: true); + } + } + + private static string Normalize(string code) + => code.Replace("\r\n", "\n").Trim(); + + private static async Task<(int ExitCode, string Output)> RunDotnetAsync(string workingDirectory, string arguments) + { + var info = new ProcessStartInfo("dotnet", arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + info.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = Process.Start(info)!; + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, stdout + stderr); + } +} diff --git a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs index 37098b0fe..bcc718671 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerRegistry.cs @@ -131,4 +131,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + // F# counterpart so `codegen write --language fsharp` can emit the static HandlerRegistry. F# + // method bodies are expressions (no `return`/`;`): an empty array, or an F# array literal of + // typeof<...> values. + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + if (_types.Length == 0) + { + writer.Write("System.Array.Empty()"); + } + else + { + var literals = string.Join("; ", _types.Select(t => $"typeof<{t.FSharpName()}>")); + writer.Write($"[| {literals} |]"); + } + + Next?.GenerateFSharpCode(method, writer); + } }