diff --git a/jasperfx.sln b/jasperfx.sln index 8182e18..7109d0e 100644 --- a/jasperfx.sln +++ b/jasperfx.sln @@ -75,6 +75,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JasperFx.AotSmoke", "src\Ja EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JasperFx.SourceGenerator", "src\JasperFx.SourceGenerator\JasperFx.SourceGenerator.csproj", "{E48F04F5-A8A3-4C47-9965-53C9D5DDC806}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JasperFx.SourceGenerator.Tests", "src\JasperFx.SourceGenerator.Tests\JasperFx.SourceGenerator.Tests.csproj", "{2952E461-E26E-4B9F-83D1-C4D1944B1F7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -403,6 +405,18 @@ Global {E48F04F5-A8A3-4C47-9965-53C9D5DDC806}.Release|x64.Build.0 = Release|Any CPU {E48F04F5-A8A3-4C47-9965-53C9D5DDC806}.Release|x86.ActiveCfg = Release|Any CPU {E48F04F5-A8A3-4C47-9965-53C9D5DDC806}.Release|x86.Build.0 = Release|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|x64.ActiveCfg = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|x64.Build.0 = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|x86.ActiveCfg = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Debug|x86.Build.0 = Debug|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|Any CPU.Build.0 = Release|Any CPU + {2952E461-E26E-4B9F-83D1-C4D1944B1F7C}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -423,5 +437,6 @@ Global {8D7BF9A9-0345-4FBA-9972-1F8413006DC2} = {A1BBEFB2-1FDB-4A32-8D00-DD5AA8E1E43A} {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} EndGlobalSection EndGlobal diff --git a/src/JasperFx.SourceGenerator.Tests/ExtensionDiscoveryGeneratorTests.cs b/src/JasperFx.SourceGenerator.Tests/ExtensionDiscoveryGeneratorTests.cs new file mode 100644 index 0000000..fcc163b --- /dev/null +++ b/src/JasperFx.SourceGenerator.Tests/ExtensionDiscoveryGeneratorTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using JasperFx; +using JasperFx.SourceGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Shouldly; + +namespace JasperFx.SourceGenerator.Tests; + +public class ExtensionDiscoveryGeneratorTests +{ + private static string? RunGenerator(string source, OutputKind outputKind) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + var references = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IJasperFxExtension).Assembly.Location), + MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")), + MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Collections.dll")), + }; + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [syntaxTree], + references: references, + options: new CSharpCompilationOptions(outputKind)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ExtensionDiscoveryGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + + return driver.GetRunResult().GeneratedTrees + .Select(t => t.GetText().ToString()) + .FirstOrDefault(t => t.Contains("DiscoveredExtensions")); + } + + [Fact] + public void marker_implementer_in_executable_assembly_is_discovered() + { + var manifest = RunGenerator(""" + namespace App; + public class MyExtension : JasperFx.IJasperFxExtension { } + """, OutputKind.ConsoleApplication); + + manifest.ShouldNotBeNull(); + manifest.ShouldContain("typeof(global::App.MyExtension)"); + } + + [Fact] + public void library_without_jasperfx_assembly_attribute_emits_nothing() + { + // Not eligible: a plain library that isn't a [JasperFxAssembly] and isn't an executable. + var manifest = RunGenerator(""" + namespace Lib; + public class MyExtension : JasperFx.IJasperFxExtension { } + """, OutputKind.DynamicallyLinkedLibrary); + + manifest.ShouldBeNull(); + } + + [Fact] + public void jasperfx_assembly_attribute_makes_a_library_eligible() + { + var manifest = RunGenerator(""" + [assembly: JasperFx.JasperFxAssembly] + namespace Lib; + public class MyExtension : JasperFx.IJasperFxExtension { } + """, OutputKind.DynamicallyLinkedLibrary); + + manifest.ShouldNotBeNull(); + manifest.ShouldContain("typeof(global::Lib.MyExtension)"); + } + + [Fact] + public void generic_module_attribute_declared_type_is_discovered_and_deduped() + { + // Mirrors Wolverine's [WolverineModule] shape: a generic attribute deriving from + // JasperFxAssemblyAttribute. The declared type is also a marker implementer, so it must + // appear exactly once. + var manifest = RunGenerator(""" + using JasperFx; + [assembly: Lib.Module] + namespace Lib; + public class Module : JasperFxAssemblyAttribute { } + public class MyExtension : IJasperFxExtension { } + """, OutputKind.DynamicallyLinkedLibrary); + + manifest.ShouldNotBeNull(); + var occurrences = manifest!.Split(["typeof(global::Lib.MyExtension)"], System.StringSplitOptions.None).Length - 1; + occurrences.ShouldBe(1); + } + + [Fact] + public void abstract_marker_types_are_skipped() + { + var manifest = RunGenerator(""" + [assembly: JasperFx.JasperFxAssembly] + namespace Lib; + public abstract class AbstractExtension : JasperFx.IJasperFxExtension { } + public class ConcreteExtension : JasperFx.IJasperFxExtension { } + """, OutputKind.DynamicallyLinkedLibrary); + + manifest.ShouldNotBeNull(); + manifest.ShouldContain("typeof(global::Lib.ConcreteExtension)"); + manifest.ShouldNotContain("AbstractExtension"); + } +} diff --git a/src/JasperFx.SourceGenerator.Tests/JasperFx.SourceGenerator.Tests.csproj b/src/JasperFx.SourceGenerator.Tests/JasperFx.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..2ca6585 --- /dev/null +++ b/src/JasperFx.SourceGenerator.Tests/JasperFx.SourceGenerator.Tests.csproj @@ -0,0 +1,29 @@ + + + net9.0 + + + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/JasperFx.SourceGenerator/ExtensionDiscoveryGenerator.cs b/src/JasperFx.SourceGenerator/ExtensionDiscoveryGenerator.cs new file mode 100644 index 0000000..72f08a2 --- /dev/null +++ b/src/JasperFx.SourceGenerator/ExtensionDiscoveryGenerator.cs @@ -0,0 +1,190 @@ +// +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JasperFx.SourceGenerator; + +/// +/// Discovers JasperFx "extension" / "option" types at compile time, eliminating runtime +/// assembly scanning (e.g. Wolverine's ExtensionLoader + AssemblyFinder.FindAssemblies, and the +/// IServiceRegistrations discovery used by JasperFx command extensions). Emits a per-assembly +/// JasperFx.Generated.DiscoveredExtensions.ExtensionTypes manifest. +/// +/// Only emits for assemblies that are eligible: those carrying a [JasperFxAssembly]-derived +/// assembly attribute, OR executable (entry) assemblies. Discovers via two sources, deduped: +/// 1. the extension type(s) declared by the assembly's [JasperFxAssembly] / [WolverineModule<T>] +/// attribute (generic type arguments and typeof(...) constructor arguments), and +/// 2. concrete classes implementing the JasperFx.IJasperFxExtension marker interface. +/// +/// Consuming frameworks read the manifest from loaded assemblies and filter by the framework's +/// own extension interface (IServiceRegistrations, IWolverineExtension, ...). +/// +[Generator] +public sealed class ExtensionDiscoveryGenerator : IIncrementalGenerator +{ + private const string MarkerInterface = "JasperFx.IJasperFxExtension"; + private const string AssemblyAttribute = "JasperFx.JasperFxAssemblyAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // (1) Concrete classes in this compilation implementing the marker interface. + var markerImplementations = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => node is ClassDeclarationSyntax cds + && !cds.Modifiers.Any(SyntaxKind.AbstractKeyword) + && !cds.Modifiers.Any(SyntaxKind.StaticKeyword) + && cds.BaseList != null, + transform: static (ctx, ct) => AnalyzeClass(ctx, ct)) + .Where(static name => name != null) + .Collect(); + + // (2) Eligibility gate + assembly-attribute-declared extension types. + var compilationInfo = context.CompilationProvider.Select(static (c, ct) => AnalyzeCompilation(c, ct)); + + var combined = markerImplementations.Combine(compilationInfo); + context.RegisterSourceOutput(combined, static (spc, pair) => Execute(spc, pair.Left, pair.Right)); + } + + // Returns the fully-qualified type name if the class implements JasperFx.IJasperFxExtension. + private static string? AnalyzeClass(GeneratorSyntaxContext ctx, CancellationToken ct) + { + var classDecl = (ClassDeclarationSyntax)ctx.Node; + if (ctx.SemanticModel.GetDeclaredSymbol(classDecl, ct) is not INamedTypeSymbol symbol) + { + return null; + } + + if (symbol.IsAbstract || symbol.IsStatic) + { + return null; + } + + var implementsMarker = symbol.AllInterfaces.Any(i => + i.ToDisplayString() == MarkerInterface); + + return implementsMarker + ? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : null; + } + + private static CompilationInfo AnalyzeCompilation(Compilation compilation, CancellationToken ct) + { + var declared = new List(); + var hasJasperFxAssemblyAttribute = false; + + foreach (var attr in compilation.Assembly.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass == null || !DerivesFromAssemblyAttribute(attrClass)) + { + continue; + } + + hasJasperFxAssemblyAttribute = true; + + // [WolverineModule] — the declared type is the attribute's generic argument. + foreach (var typeArg in attrClass.TypeArguments.OfType()) + { + declared.Add(typeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + // [JasperFxAssembly(typeof(T))] / [WolverineModule(typeof(T))] — typeof() ctor args. + foreach (var arg in attr.ConstructorArguments) + { + if (arg.Kind == TypedConstantKind.Type && arg.Value is INamedTypeSymbol typeArg) + { + declared.Add(typeArg.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + } + + var isExecutable = compilation.Options.OutputKind is OutputKind.ConsoleApplication + or OutputKind.WindowsApplication; + + return new CompilationInfo( + isEligible: hasJasperFxAssemblyAttribute || isExecutable, + declaredTypes: declared.ToImmutableArray()); + } + + private static bool DerivesFromAssemblyAttribute(INamedTypeSymbol attributeClass) + { + for (var current = attributeClass; current != null; current = current.BaseType) + { + // OriginalDefinition collapses the generic WolverineModuleAttribute to its open form. + if (current.OriginalDefinition.ToDisplayString() == AssemblyAttribute + || current.ToDisplayString() == AssemblyAttribute) + { + return true; + } + } + + return false; + } + + private static void Execute(SourceProductionContext context, ImmutableArray markerTypes, + CompilationInfo compilation) + { + if (!compilation.IsEligible) + { + return; + } + + var extensionTypes = markerTypes + .Where(x => x != null) + .Select(x => x!) + .Concat(compilation.DeclaredTypes) + .Distinct() + .OrderBy(x => x, System.StringComparer.Ordinal) + .ToList(); + + if (extensionTypes.Count == 0) + { + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine(); + sb.AppendLine("namespace JasperFx.Generated;"); + sb.AppendLine(); + sb.AppendLine("/// "); + sb.AppendLine("/// Source-generated manifest of JasperFx extension/option types discovered at compile time."); + sb.AppendLine("/// Consumed by framework extension loaders to bypass runtime assembly scanning."); + sb.AppendLine("/// "); + sb.AppendLine("internal static class DiscoveredExtensions"); + sb.AppendLine("{"); + sb.AppendLine(" public static IReadOnlyList ExtensionTypes { get; } = new Type[]"); + sb.AppendLine(" {"); + + foreach (var type in extensionTypes) + { + sb.AppendLine($" typeof({type}),"); + } + + sb.AppendLine(" };"); + sb.AppendLine("}"); + + context.AddSource("DiscoveredExtensions.g.cs", sb.ToString()); + } + + private readonly struct CompilationInfo + { + public CompilationInfo(bool isEligible, ImmutableArray declaredTypes) + { + IsEligible = isEligible; + DeclaredTypes = declaredTypes; + } + + public bool IsEligible { get; } + public ImmutableArray DeclaredTypes { get; } + } +} diff --git a/src/JasperFx/CommandLine/IServiceRegistrations.cs b/src/JasperFx/CommandLine/IServiceRegistrations.cs index 3c9056d..fddb987 100644 --- a/src/JasperFx/CommandLine/IServiceRegistrations.cs +++ b/src/JasperFx/CommandLine/IServiceRegistrations.cs @@ -6,7 +6,7 @@ namespace JasperFx.CommandLine; /// Implementations of this interface can be used to define /// service registrations to be loaded by JasperFx command extensions /// -public interface IServiceRegistrations +public interface IServiceRegistrations : IJasperFxExtension { void Configure(IServiceCollection services); } \ No newline at end of file diff --git a/src/JasperFx/GeneratedExtensionManifest.cs b/src/JasperFx/GeneratedExtensionManifest.cs new file mode 100644 index 0000000..6a2c82b --- /dev/null +++ b/src/JasperFx/GeneratedExtensionManifest.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace JasperFx; + +/// +/// Reads the source-generated JasperFx.Generated.DiscoveredExtensions manifests that +/// JasperFx.SourceGenerator emits into eligible assemblies ([JasperFxAssembly]-marked or +/// executable). Lets consuming frameworks discover their extension/option types without the +/// filesystem-probing assembly scan (AssemblyFinder), then filter by their own extension +/// interface (e.g. IServiceRegistrations, Wolverine's IWolverineExtension). +/// +public static class GeneratedExtensionManifest +{ + private const string ManifestTypeName = "JasperFx.Generated.DiscoveredExtensions"; + private const string ManifestPropertyName = "ExtensionTypes"; + + /// + /// All extension types discovered at compile time across the supplied assemblies, deduplicated. + /// + public static IReadOnlyList ReadFrom(IEnumerable assemblies) + { + var results = new List(); + var seen = new HashSet(); + + foreach (var assembly in assemblies) + { + foreach (var type in ReadFromAssembly(assembly)) + { + if (seen.Add(type)) + { + results.Add(type); + } + } + } + + return results; + } + + /// + /// All compile-time-discovered extension types across the currently loaded, non-dynamic + /// assemblies. This avoids the filesystem-probing assembly scan; if no manifests are present + /// (the source generator wasn't run), it simply returns an empty list and the caller should + /// fall back to its reflective discovery. + /// + public static IReadOnlyList ReadFromLoadedAssemblies() + { + return ReadFrom(AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic)); + } + + /// + /// True if any loaded assembly carries a source-generated extension manifest, i.e. the + /// generator is active and discovery results can be trusted without reflective scanning. + /// + public static bool AnyManifestPresent() + { + return AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic) + .Any(a => a.GetType(ManifestTypeName) != null); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JasperFx.Generated.DiscoveredExtensions is emitted by JasperFx.SourceGenerator as ordinary code in the consuming assembly and survives trimming; absence degrades to an empty result.")] + [UnconditionalSuppressMessage("Trimming", "IL2075", + Justification = "ExtensionTypes is a source-generated static property in the consuming assembly.")] + private static IEnumerable ReadFromAssembly(Assembly assembly) + { + if (assembly.IsDynamic) + { + yield break; + } + + var manifestType = assembly.GetType(ManifestTypeName); + var property = manifestType?.GetProperty(ManifestPropertyName, BindingFlags.Public | BindingFlags.Static); + if (property?.GetValue(null) is not IEnumerable types) + { + yield break; + } + + foreach (var type in types) + { + yield return type; + } + } +} diff --git a/src/JasperFx/IJasperFxExtension.cs b/src/JasperFx/IJasperFxExtension.cs new file mode 100644 index 0000000..f6e65ac --- /dev/null +++ b/src/JasperFx/IJasperFxExtension.cs @@ -0,0 +1,15 @@ +namespace JasperFx; + +/// +/// Marker interface for "extension" / "option" types that the JasperFx source generator +/// discovers at compile time — in assemblies carrying a +/// (or one derived from it, such as Wolverine's WolverineModuleAttribute) or in an executable +/// (entry) assembly — and emits into the JasperFx.Generated.DiscoveredExtensions manifest. +/// Consuming frameworks read that manifest to register/apply their extensions without runtime +/// assembly scanning (AOT/trim-clean). +/// +/// Framework extension interfaces extend this so their implementers are discoverable, e.g. +/// here, and Wolverine's +/// IWolverineExtension in the Wolverine package. +/// +public interface IJasperFxExtension;