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
61 changes: 49 additions & 12 deletions src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,64 @@ private static string GenerateSource(
sb.AppendLine();
}

var containingTypes = GetContainingTypes(type);
var indentLevel = 0;
foreach (var containingType in containingTypes)
{
AppendTypeDeclaration(sb, containingType, indentLevel);
Comment on lines +92 to +96
sb.AppendLine();
sb.AppendLine(new string(' ', indentLevel * 4) + "{");
indentLevel++;
}

AppendTypeDeclaration(sb, type, indentLevel);
var indent = new string(' ', indentLevel * 4);
sb.AppendLine();
sb.AppendLine(indent + "{");
var memberIndent = indent + " ";
var bodyIndent = memberIndent + " ";
sb.Append(memberIndent).Append("public static global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Builder ").Append(factoryName).AppendLine("()");
sb.AppendLine(memberIndent + "{");
sb.Append(bodyIndent).Append("return global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Create(").Append(ToLiteral(bridgeName)).AppendLine(");");
sb.AppendLine(memberIndent + "}");
sb.AppendLine(indent + "}");
for (var i = containingTypes.Length - 1; i >= 0; i--)
{
sb.AppendLine(new string(' ', i * 4) + "}");
}

return sb.ToString();
}

private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type)
{
var containingTypes = new Stack<INamedTypeSymbol>();
for (var current = type.ContainingType; current is not null; current = current.ContainingType)
{
containingTypes.Push(current);
}

return containingTypes.ToArray();
}

private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel)
{
sb.Append(new string(' ', indentLevel * 4));
sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' ');
if (type.IsStatic)
sb.Append("static ");
else if (type.IsAbstract && type.TypeKind == TypeKind.Class)
sb.Append("abstract ");
else if (type.IsSealed && type.TypeKind == TypeKind.Class)
sb.Append("sealed ");

sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine();
sb.AppendLine("{");
sb.Append(" public static global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Builder ").Append(factoryName).AppendLine("()");
sb.Append(" => global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Create(").Append(ToLiteral(bridgeName)).AppendLine(");");
sb.AppendLine("}");
return sb.ToString();
sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name);
}

private static string? GetNamedString(AttributeData attribute, string name)
=> attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string;

private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\"";

private static string GetAccessibility(Accessibility accessibility)
=> accessibility switch
{
Expand All @@ -116,9 +158,4 @@ private static string GetAccessibility(Accessibility accessibility)
Accessibility.ProtectedOrInternal => "protected internal",
_ => "internal"
};

private static string? GetNamedString(AttributeData attribute, string name)
=> attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string;

private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\"";
}
190 changes: 155 additions & 35 deletions test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public sealed partial class MessagingBridgeGeneratorTests(ITestOutputHelper outp
{
[Scenario("Generates messaging bridge factory")]
[Fact]
public Task Generates_MessagingBridge_Factory()
=> Given("a partial host marked with GenerateMessagingBridge", () => """
public Task Generates_Messaging_Bridge_Factory()
=> Given("a partial host marked with GenerateMessagingBridge", () => Compile("""
using PatternKit.Generators.Messaging;

namespace MyApp;
Expand All @@ -22,50 +22,170 @@ public sealed record CommerceOrder(string Id);

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), FactoryName = "Build", BridgeName = "partner-commerce")]
public static partial class PartnerCommerceBridge;
""")
.When("the generator runs", source =>
{
var comp = CreateCompilation(source, nameof(Generates_MessagingBridge_Factory));
_ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), out var run, out _);
return run.Results.Single().GeneratedSources.Single().SourceText.ToString();
})
.Then("the generated factory returns a configured builder", text =>
{
ScenarioExpect.Contains("MessagingBridge<global::MyApp.PartnerOrder, global::MyApp.CommerceOrder>.Builder Build()", text);
ScenarioExpect.Contains("MessagingBridge<global::MyApp.PartnerOrder, global::MyApp.CommerceOrder>.Create(@\"partner-commerce\")", text);
})
.AssertPassed();
"""))
.Then("the generated factory returns a configured builder", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
var source = ScenarioExpect.Single(result.GeneratedSources);
ScenarioExpect.Contains("public static partial class PartnerCommerceBridge", source);
ScenarioExpect.Contains("MessagingBridge<global::MyApp.PartnerOrder, global::MyApp.CommerceOrder>.Builder Build()", source);
ScenarioExpect.Contains("MessagingBridge<global::MyApp.PartnerOrder, global::MyApp.CommerceOrder>.Create(@\"partner-commerce\")", source);
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

[Scenario("Reports diagnostic for non-partial messaging bridge declarations")]
[Fact]
public Task Reports_Diagnostic_For_Non_Partial_Messaging_Bridge_Declarations()
=> Given("a non-partial messaging bridge declaration", () => Compile("""
using PatternKit.Generators.Messaging;

[Scenario("Reports messaging bridge diagnostics")]
public sealed record PartnerOrder(string Id);
public sealed record CommerceOrder(string Id);

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
public static class PartnerCommerceBridge;
"""))
.Then("the diagnostic identifies the host", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKMBR001"))
.AssertPassed();

[Scenario("Reports diagnostic for invalid messaging bridge configuration")]
[Theory]
[InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] public static class PartnerCommerceBridge;", "PKMBR001")]
[InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), BridgeName = \"\")] public static partial class PartnerCommerceBridge;", "PKMBR002")]
public Task Reports_MessagingBridge_Diagnostics(string declaration, string expected)
=> Given("an invalid GenerateMessagingBridge declaration", () => $$"""
[InlineData("FactoryName = \"\"", "PKMBR002")]
[InlineData("BridgeName = \"\"", "PKMBR002")]
[InlineData("FactoryName = \" \"", "PKMBR002")]
[InlineData("BridgeName = \" \"", "PKMBR002")]
public Task Reports_Diagnostic_For_Invalid_Messaging_Bridge_Configuration(string invalidConfiguration, string expected)
=> Given("an invalid messaging bridge declaration", () => Compile($$"""
using PatternKit.Generators.Messaging;

namespace MyApp;
public sealed record PartnerOrder(string Id);
public sealed record CommerceOrder(string Id);

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), {{invalidConfiguration}})]
public static partial class PartnerCommerceBridge;
"""))
.Then("the expected diagnostic is reported", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == expected))
.AssertPassed();

[Scenario("Generates messaging bridge defaults and type shapes")]
[Fact]
public Task Generates_Messaging_Bridge_Defaults_And_Type_Shapes()
=> Given("messaging bridge declarations using default names and different host shapes", () => Compile("""
using PatternKit.Generators.Messaging;

namespace Demo;

public sealed record PartnerOrder(string Id);
public sealed record CommerceOrder(string Id);

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
internal abstract partial class AbstractBridge;

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), BridgeName = "tenant\"bridge")]
public sealed partial class SealedBridge;

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
internal partial struct StructBridge;
"""))
.Then("generated sources preserve host shape and configured names", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
ScenarioExpect.Equal(3, result.GeneratedSources.Count);

var combined = string.Join("\n", result.GeneratedSources);
ScenarioExpect.Contains("internal abstract partial class AbstractBridge", combined);
ScenarioExpect.Contains("Create()", combined);
ScenarioExpect.Contains("MessagingBridge<global::Demo.PartnerOrder, global::Demo.CommerceOrder>.Create(@\"messaging-bridge\")", combined);
ScenarioExpect.Contains("public sealed partial class SealedBridge", combined);
ScenarioExpect.Contains("MessagingBridge<global::Demo.PartnerOrder, global::Demo.CommerceOrder>.Create(@\"tenant\"\"bridge\")", combined);
ScenarioExpect.Contains("internal partial struct StructBridge", combined);
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

[Scenario("Generates nested messaging bridge host wrappers")]
[Fact]
public Task Generates_Nested_Messaging_Bridge_Host_Wrappers()
=> Given("nested messaging bridge declarations with non-public accessibility", () => Compile("""
using PatternKit.Generators.Messaging;

namespace Demo;

public sealed record PartnerOrder(string Id);
public sealed record CommerceOrder(string Id);

{{declaration}}
""")
.When("the generator runs", source =>
public partial class BridgeContainer
{
var comp = CreateCompilation(source, nameof(Reports_MessagingBridge_Diagnostics) + expected);
_ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), out var run, out _);
return run.Diagnostics.Select(static d => d.Id).ToArray();
})
.Then("the expected diagnostic is reported", ids =>
ScenarioExpect.Contains(expected, ids))
.AssertPassed();

private static Compilation CreateCompilation(string source, string assemblyName)
=> RoslynTestHelpers.CreateCompilation(source, assemblyName,
private partial class PrivateHost
{
[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
protected partial class ProtectedBridge;

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
private protected partial class PrivateProtectedBridge;

[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))]
protected internal partial class ProtectedInternalBridge;
}
}
"""))
.Then("generated sources preserve containing partial type wrappers", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
ScenarioExpect.Equal(3, result.GeneratedSources.Count);

var combined = string.Join("\n", result.GeneratedSources);
ScenarioExpect.Contains("public partial class BridgeContainer", combined);
ScenarioExpect.Contains("private partial class PrivateHost", combined);
ScenarioExpect.Contains("protected partial class ProtectedBridge", combined);
ScenarioExpect.Contains("private protected partial class PrivateProtectedBridge", combined);
ScenarioExpect.Contains("protected internal partial class ProtectedInternalBridge", combined);
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

[Scenario("Skips malformed messaging bridge type arguments")]
[Theory]
[InlineData("null!", "typeof(CommerceOrder)")]
[InlineData("typeof(PartnerOrder)", "null!")]
public Task Skips_Malformed_Messaging_Bridge_Type_Arguments(string inboundType, string outboundType)
=> Given("a messaging bridge declaration with a null message type", () => Compile($$"""
using PatternKit.Generators.Messaging;

public sealed record PartnerOrder(string Id);
public sealed record CommerceOrder(string Id);

[GenerateMessagingBridge({{inboundType}}, {{outboundType}})]
public static partial class PartnerCommerceBridge;
"""))
.Then("no source is generated", result =>
ScenarioExpect.Empty(result.GeneratedSources))
.AssertPassed();

private static GeneratorResult Compile(string source)
{
var compilation = RoslynTestHelpers.CreateCompilation(source, "MessagingBridgeGeneratorTests",
extra:
[
MetadataReference.CreateFromFile(typeof(GenerateMessagingBridgeAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Bridges.MessagingBridge<,>).Assembly.Location)
MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Bridges.MessagingBridge<,>).Assembly.Location)
]);
_ = RoslynTestHelpers.Run(compilation, new MessagingBridgeGenerator(), out var run, out var updated);
var result = run.Results.Single();
var emit = updated.Emit(Stream.Null);
return new GeneratorResult(
result.Diagnostics.ToArray(),
result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(),
emit.Success,
emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray());
}

private sealed record GeneratorResult(
IReadOnlyList<Diagnostic> Diagnostics,
IReadOnlyList<string> GeneratedSources,
bool EmitSuccess,
IReadOnlyList<string> EmitDiagnostics);
}
Loading