Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/fsharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>true</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>6.3.0</Version>
<Version>6.3.1</Version>
<RepositoryUrl>$(PackageProjectUrl)</RepositoryUrl>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
Expand Down
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@
<PackageVersion Include="Grpc.StatusProto" Version="2.76.0" />
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
<PackageVersion Include="HtmlTags" Version="9.0.0" />
<PackageVersion Include="JasperFx" Version="2.4.0" />
<PackageVersion Include="JasperFx.Events" Version="2.4.0" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.4.0" />
<PackageVersion Include="JasperFx" Version="2.4.1" />
<PackageVersion Include="JasperFx.Events" Version="2.4.1" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.4.1" />
<!-- RuntimeCompiler is on its own 5.x line (the Roslyn compiler package) — not the 2.1.x
family; it stays at 5.0.0. -->
<PackageVersion Include="JasperFx.RuntimeCompiler" Version="5.0.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.4.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.4.1" />
<PackageVersion Include="Marten" Version="9.2.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageVersion Include="Polecat" Version="4.2.1" />
Expand Down
33 changes: 31 additions & 2 deletions docs/tutorials/fsharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` `<Compile>` list (the registry and adapters depend on your handler/message types, so
list them after those):

```xml
<ItemGroup>
<Compile Include="Domain.fs" />
<!-- generated by: dotnet run -- codegen write --language fsharp -->
<Compile Include="Internal/Generated/WolverineHandlers/GeneratedHandlerRegistry.fs" />
<Compile Include="Internal/Generated/WolverineHandlers/MyMessageHandlerNNNNN.fs" />
</ItemGroup>
```

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:
Expand Down
22 changes: 22 additions & 0 deletions src/Testing/Wolverine.Behavioural.FSharpApp/Program.fs
Original file line number Diff line number Diff line change
@@ -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<BehaviouralPingHandler>) so the generated handler
// type-name hash is identical to the one the behavioural run-step computes under TypeLoadMode.Static.
[<EntryPoint>]
let main args =
Host
.CreateDefaultBuilder(args)
.UseWolverine(fun opts ->
opts.Discovery.DisableConventionalDiscovery() |> ignore
opts.Discovery.IncludeType<BehaviouralPingHandler>() |> ignore)
.RunJasperFxCommands(args)
.GetAwaiter()
.GetResult()
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
<TargetFrameworks>net9.0</TargetFrameworks>
<IsPackable>false</IsPackable>

<!-- An exe so the JasperFx codegen CLI verb works against it (dotnet run, codegen write,
language fsharp); it is also referenced as a library (the ApplicationAssembly) by the
behavioural run-step test. -->
<OutputType>Exe</OutputType>

<LangVersion></LangVersion>
<ImplicitUsings>false</ImplicitUsings>
<Nullable>disable</Nullable>
Expand All @@ -25,6 +30,7 @@
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Generated.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[Collection("BehaviouralFSharp")]
public class BehaviouralRunStep
{
private readonly ITestOutputHelper _output;
Expand Down
108 changes: 108 additions & 0 deletions src/Testing/Wolverine.Behavioural.FSharpTests/CodegenWriteFSharpCli.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Proves the JasperFx <c>codegen write --language fsharp</c> 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
/// <c>Generated.fs</c> — which the behavioural run-step already compiles and executes under
/// <see cref="JasperFx.CodeGeneration.TypeLoadMode.Static" />. So: the CLI produces the same F#
/// that is proven to run.
/// </summary>
[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<WolverineBehaviouralFSharpApp.BehaviouralPingHandler>");
}
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);
}
}
18 changes: 18 additions & 0 deletions src/Wolverine/Runtime/Handlers/HandlerRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<System.Type>()");
}
else
{
var literals = string.Join("; ", _types.Select(t => $"typeof<{t.FSharpName()}>"));
writer.Write($"[| {literals} |]");
}

Next?.GenerateFSharpCode(method, writer);
}
}
Loading