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
15 changes: 15 additions & 0 deletions jasperfx.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
111 changes: 111 additions & 0 deletions src/JasperFx.SourceGenerator.Tests/ExtensionDiscoveryGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -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>
{
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<T>] 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<Lib.MyExtension>]
namespace Lib;
public class Module<T> : 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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<!-- Override Directory.Build.props multi-targeting -->
<TargetFrameworks></TargetFrameworks>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\JasperFx.SourceGenerator\JasperFx.SourceGenerator.csproj" />
<ProjectReference Include="..\JasperFx\JasperFx.csproj" />
</ItemGroup>
</Project>
190 changes: 190 additions & 0 deletions src/JasperFx.SourceGenerator/ExtensionDiscoveryGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// <auto-generated />
#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;

/// <summary>
/// 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
/// <c>JasperFx.Generated.DiscoveredExtensions.ExtensionTypes</c> 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&lt;T&gt;]
/// attribute (generic type arguments and typeof(...) constructor arguments), and
/// 2. concrete classes implementing the <c>JasperFx.IJasperFxExtension</c> marker interface.
///
/// Consuming frameworks read the manifest from loaded assemblies and filter by the framework's
/// own extension interface (IServiceRegistrations, IWolverineExtension, ...).
/// </summary>
[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<string>();
var hasJasperFxAssemblyAttribute = false;

foreach (var attr in compilation.Assembly.GetAttributes())
{
var attrClass = attr.AttributeClass;
if (attrClass == null || !DerivesFromAssemblyAttribute(attrClass))
{
continue;
}

hasJasperFxAssemblyAttribute = true;

// [WolverineModule<T>] — the declared type is the attribute's generic argument.
foreach (var typeArg in attrClass.TypeArguments.OfType<INamedTypeSymbol>())
{
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<T> to its open form.
if (current.OriginalDefinition.ToDisplayString() == AssemblyAttribute
|| current.ToDisplayString() == AssemblyAttribute)
{
return true;
}
}

return false;
}

private static void Execute(SourceProductionContext context, ImmutableArray<string?> 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("// <auto-generated />");
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("/// <summary>");
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("/// </summary>");
sb.AppendLine("internal static class DiscoveredExtensions");
sb.AppendLine("{");
sb.AppendLine(" public static IReadOnlyList<Type> 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<string> declaredTypes)
{
IsEligible = isEligible;
DeclaredTypes = declaredTypes;
}

public bool IsEligible { get; }
public ImmutableArray<string> DeclaredTypes { get; }
}
}
2 changes: 1 addition & 1 deletion src/JasperFx/CommandLine/IServiceRegistrations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// </summary>
public interface IServiceRegistrations
public interface IServiceRegistrations : IJasperFxExtension
{
void Configure(IServiceCollection services);
}
Loading
Loading