diff --git a/Directory.Build.props b/Directory.Build.props index 5ad75623..e17aa47b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ JasperFx.Events, JasperFx.Events.SourceGenerator) set $(JasperFxVersion) so they always release together. Bump this one value to release the whole set. --> - 2.4.0 + 2.4.1 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/build/Build.cs b/build/Build.cs index 3d23a935..ddb75d10 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -105,6 +105,10 @@ partial class Build : NukeBuild DotNet("run --framework net9.0 -- describe --environment Testing --applicationName Different --contentRoot /bin", Solution.TestHarnesses.CommandLineRunner.Directory); DotNet("run --framework net9.0 -- describe --environment=Testing --applicationName=Different --contentRoot=/bin", Solution.TestHarnesses.CommandLineRunner.Directory); DotNet("run --framework net9.0 -- codegen preview --start", Solution.TestHarnesses.GeneratorTarget.Directory); + + // Validate the `--language fsharp` codegen flag is wired through the CLI and emits F#. + // (Compilable/runnable pre-generated F# is proven downstream against real handler chains.) + DotNet("run --framework net9.0 -- codegen preview --language fsharp --start", Solution.TestHarnesses.GeneratorTarget.Directory); }); /// diff --git a/src/CodegenTests/CodegenLanguageTests.cs b/src/CodegenTests/CodegenLanguageTests.cs new file mode 100644 index 00000000..44d5be98 --- /dev/null +++ b/src/CodegenTests/CodegenLanguageTests.cs @@ -0,0 +1,105 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodegenTests; + +public class CodegenLanguageTests +{ + [Fact] + public void default_language_is_csharp() + { + new DynamicCodeBuilder(new ServiceCollection().BuildServiceProvider(), []) + .Language.ShouldBe(CodegenLanguage.csharp); + } + + [Fact] + public void preview_emits_csharp_by_default() + { + var code = Build(CodegenLanguage.csharp).GenerateAllCode(); + + code.ShouldContain("namespace"); + code.ShouldContain("class GreeterType"); // C# class syntax (F# would be "type GreeterType") + } + + [Fact] + public void preview_emits_fsharp_when_language_is_fsharp() + { + var code = Build(CodegenLanguage.fsharp).GenerateAllCode(); + + code.ShouldContain("// "); + code.ShouldContain("namespace"); + code.ShouldContain("type GreeterType"); + code.ShouldNotContain("public class"); + } + + [Fact] + public void write_uses_cs_extension_by_default() + { + var written = WriteToTempAndCollect(CodegenLanguage.csharp); + + written.ShouldContain(f => f.EndsWith(".cs")); + written.ShouldNotContain(f => f.EndsWith(".fs")); + } + + [Fact] + public void write_uses_fs_extension_and_fsharp_content_when_language_is_fsharp() + { + var written = WriteToTempAndCollect(CodegenLanguage.fsharp); + + written.ShouldContain(f => f.EndsWith(".fs")); + written.ShouldNotContain(f => f.EndsWith(".cs")); + + var fsFile = written.Single(f => f.EndsWith(".fs")); + File.ReadAllText(fsFile).ShouldContain("namespace"); + } + + private static DynamicCodeBuilder Build(CodegenLanguage language, GenerationRules? rules = null) + { + var collection = new SampleCollection(rules ?? new GenerationRules("Generated"), new SampleFile("Greeter")); + return new DynamicCodeBuilder(new ServiceCollection().BuildServiceProvider(), [collection]) + { + Language = language + }; + } + + private static List WriteToTempAndCollect(CodegenLanguage language) + { + var dir = Path.Combine(Path.GetTempPath(), "jasperfx-codegen-language-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(dir); + var rules = new GenerationRules("Generated") { GeneratedCodeOutputPath = dir }; + + var written = new List(); + Build(language, rules).WriteGeneratedCode(written.Add); + return written; + } + + private sealed class SampleFile(string fileName) : ICodeFile + { + public string FileName { get; } = fileName; + + public void AssembleTypes(GeneratedAssembly assembly) => assembly.AddType(FileName + "Type", typeof(object)); + + public Task AttachTypes(GenerationRules rules, System.Reflection.Assembly assembly, + IServiceProvider? services, string containingNamespace) => Task.FromResult(false); + + public bool AttachTypesSynchronously(GenerationRules rules, System.Reflection.Assembly assembly, + IServiceProvider? services, string containingNamespace) => false; + + public void AssertServiceLocationsAreAllowed(ServiceLocationReport[] reports, IServiceProvider? services) { } + + public bool TryReplaceServiceProvider(out Variable serviceProvider) + { + serviceProvider = default!; + return false; + } + } + + private sealed class SampleCollection(GenerationRules rules, params ICodeFile[] files) : ICodeFileCollection + { + public string ChildNamespace => "Sample"; + public GenerationRules Rules { get; } = rules; + public IReadOnlyList BuildFiles() => files; + } +} diff --git a/src/JasperFx/CodeGeneration/CodegenLanguage.cs b/src/JasperFx/CodeGeneration/CodegenLanguage.cs new file mode 100644 index 00000000..cf9ccdb8 --- /dev/null +++ b/src/JasperFx/CodeGeneration/CodegenLanguage.cs @@ -0,0 +1,11 @@ +namespace JasperFx.CodeGeneration; + +/// +/// Target language for generated code. C# is the default and supports both the dynamic (runtime +/// Roslyn) and pre-generated (static) models. F# targets the pre-generated/static model only. +/// +public enum CodegenLanguage +{ + csharp, + fsharp +} diff --git a/src/JasperFx/CodeGeneration/Commands/GenerateCodeCommand.cs b/src/JasperFx/CodeGeneration/Commands/GenerateCodeCommand.cs index d4e8b722..83d5e2aa 100644 --- a/src/JasperFx/CodeGeneration/Commands/GenerateCodeCommand.cs +++ b/src/JasperFx/CodeGeneration/Commands/GenerateCodeCommand.cs @@ -42,9 +42,17 @@ public override bool Execute(GenerateCodeInput input) var builder = new DynamicCodeBuilder(host.Services, collections) { - ServiceVariableSource = host.Services.GetService() + ServiceVariableSource = host.Services.GetService(), + Language = input.LanguageFlag }; + if (input.LanguageFlag == CodegenLanguage.fsharp && input.Action == CodeAction.test) + { + AnsiConsole.MarkupLine( + "[red]'codegen test' does not support --language fsharp; the test action uses in-memory C# compilation. Use 'write' or 'preview' for F#.[/]"); + return false; + } + switch (input.Action) { case CodeAction.preview: diff --git a/src/JasperFx/CodeGeneration/Commands/GenerateCodeInput.cs b/src/JasperFx/CodeGeneration/Commands/GenerateCodeInput.cs index c1cab966..dd4463c3 100644 --- a/src/JasperFx/CodeGeneration/Commands/GenerateCodeInput.cs +++ b/src/JasperFx/CodeGeneration/Commands/GenerateCodeInput.cs @@ -9,6 +9,9 @@ public class GenerateCodeInput : NetCoreInput [Description("Optionally limit the preview to only one type of code generation")] public string? TypeFlag { get; set; } + [Description("Target language for the generated code (csharp or fsharp). Default is csharp")] + public CodegenLanguage LanguageFlag { get; set; } = CodegenLanguage.csharp; + [Description("Start the IHost instead of just building it. Use this when code generation participants are registered during host startup (e.g., ASP.NET Core Startup.Configure)")] public bool StartFlag { get; set; } } \ No newline at end of file diff --git a/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs b/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs index a4cee1b8..061857e7 100644 --- a/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs +++ b/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs @@ -30,8 +30,23 @@ public DynamicCodeBuilder(IServiceProvider services, ICodeFileCollection[] colle public IServiceVariableSource? ServiceVariableSource { get; set; } + /// + /// Target language for the generated source. F# () emits the + /// pre-generated/static code model with the .fs extension; C# is the default. + /// + public CodegenLanguage Language { get; set; } = CodegenLanguage.csharp; + public string[] ChildNamespaces => Collections.Select(x => x.ChildNamespace).ToArray(); + private string renderCode(GeneratedAssembly generatedAssembly, IServiceVariableSource? services) + { + return Language == CodegenLanguage.fsharp + ? generatedAssembly.GenerateFSharpCode(services) + : generatedAssembly.GenerateCode(services); + } + + private string fileExtension => Language == CodegenLanguage.fsharp ? ".fs" : ".cs"; + public IServiceProvider Services { get; } public ICodeFileCollection[] Collections { get; } @@ -127,7 +142,7 @@ public void WriteGeneratedCode(Action onFileWritten) // `services` is shared across every file here, so reset the prior override first. applyServiceProviderOverride(file, services); - var code = generatedAssembly.GenerateCode(services); + var code = renderCode(generatedAssembly, services); // #227: enforce ServiceLocationPolicy per-file, matching the // runtime compile path in DynamicTypeLoader.Initialize. The @@ -138,7 +153,7 @@ public void WriteGeneratedCode(Action onFileWritten) // "all generated" result from the CLI. assertServiceLocationsAllowed(file, services); - var fileName = Path.Combine(exportDirectory, file.FileName.Replace(" ", "_") + ".cs"); + var fileName = Path.Combine(exportDirectory, file.FileName.Replace(" ", "_") + fileExtension); File.WriteAllText(fileName, code); onFileWritten(fileName); } @@ -185,7 +200,7 @@ private string generateCode(ICodeFileCollection collection) // shared source. applyServiceProviderOverride(file, services); - var code = generatedAssembly.GenerateCode(services); + var code = renderCode(generatedAssembly, services); assertServiceLocationsAllowed(file, services); writer.WriteLine(code);