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
53 changes: 45 additions & 8 deletions src/PatternKit.Generators/AuditLog/AuditLogGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ private static void Generate(SourceProductionContext context, INamedTypeSymbol t

var selector = selectors[0];
if (!selector.IsStatic
|| selector.IsGenericMethod
|| selector.Parameters.Length != 1
|| !SymbolEqualityComparer.Default.Equals(selector.Parameters[0].Type, entryType)
|| !SymbolEqualityComparer.Default.Equals(selector.ReturnType, keyType))
Expand Down Expand Up @@ -108,21 +109,57 @@ private static string GenerateSource(
sb.AppendLine();
}

var containingTypes = GetContainingTypes(type);
var indentLevel = 0;
foreach (var containingType in containingTypes)
{
AppendTypeDeclaration(sb, containingType, indentLevel);
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.Application.AuditLog.InMemoryAuditLog<")
.Append(entryTypeName).Append(", ").Append(keyTypeName).Append("> ").Append(factoryName).AppendLine("()");
sb.Append(bodyIndent).Append("=> global::PatternKit.Application.AuditLog.InMemoryAuditLog<")
.Append(entryTypeName).Append(", ").Append(keyTypeName).Append(">.Create(\"").Append(Escape(logName)).Append("\", ").Append(selectorName).AppendLine(").Build();");
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.Application.AuditLog.InMemoryAuditLog<")
.Append(entryTypeName).Append(", ").Append(keyTypeName).Append("> ").Append(factoryName).AppendLine("()");
sb.Append(" => global::PatternKit.Application.AuditLog.InMemoryAuditLog<")
.Append(entryTypeName).Append(", ").Append(keyTypeName).Append(">.Create(\"").Append(Escape(logName)).Append("\", ").Append(selectorName).AppendLine(").Build();");
sb.AppendLine("}");
return sb.ToString();
sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name);
}
Comment on lines 161 to 163

private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
Expand Down
132 changes: 132 additions & 0 deletions test/PatternKit.Generators.Tests/AuditLogGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ public Task Generator_Emits_Audit_Log_Factory()
=> Given("a valid audit log declaration", () => Compile("""
using System;
using PatternKit.Generators.AuditLog;

namespace Demo;

public sealed record AuditEntry(Guid EntryId);

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid), FactoryName = "Build", LogName = "order-audit")]
public static partial class OrderAuditLog
{
Expand All @@ -29,6 +32,7 @@ public static partial class OrderAuditLog
{
ScenarioExpect.Empty(result.Diagnostics);
var source = ScenarioExpect.Single(result.GeneratedSources);
ScenarioExpect.Contains("public static partial class OrderAuditLog", source);
ScenarioExpect.Contains("Build()", source);
ScenarioExpect.Contains("InMemoryAuditLog<global::Demo.AuditEntry, global::System.Guid>.Create(\"order-audit\", SelectKey).Build()", source);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
Expand All @@ -40,6 +44,11 @@ public static partial class OrderAuditLog
[InlineData("public static class OrderAuditLog { [AuditLogKeySelector] private static Guid SelectKey(AuditEntry entry) => entry.EntryId; }", "PKAUD001")]
[InlineData("public static partial class OrderAuditLog;", "PKAUD002")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static Guid One(AuditEntry entry) => entry.EntryId; [AuditLogKeySelector] private static Guid Two(AuditEntry entry) => entry.EntryId; }", "PKAUD002")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private Guid SelectKey(AuditEntry entry) => entry.EntryId; }", "PKAUD003")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static Guid SelectKey<T>(AuditEntry entry) => entry.EntryId; }", "PKAUD003")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static Guid SelectKey() => Guid.Empty; }", "PKAUD003")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static Guid SelectKey(AuditEntry entry, string tenant) => entry.EntryId; }", "PKAUD003")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static Guid SelectKey(string entry) => Guid.Parse(entry); }", "PKAUD003")]
[InlineData("public static partial class OrderAuditLog { [AuditLogKeySelector] private static string SelectKey(AuditEntry entry) => entry.EntryId.ToString(); }", "PKAUD003")]
public Task Generator_Reports_Invalid_Audit_Log_Declarations(string declaration, string diagnosticId)
=> Given("an invalid audit log declaration", () => Compile($$"""
Expand All @@ -53,6 +62,129 @@ public sealed record AuditEntry(Guid EntryId);
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId))
.AssertPassed();

[Scenario("Generator emits audit log defaults and type shapes")]
[Fact]
public Task Generator_Emits_Audit_Log_Defaults_And_Type_Shapes()
=> Given("audit log declarations using default names and different host shapes", () => Compile("""
using System;
using PatternKit.Generators.AuditLog;

namespace Demo;

public sealed record AuditEntry(Guid EntryId);

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid))]
internal abstract partial class AbstractAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid), LogName = "tenant\\\"audit")]
public sealed partial class SealedAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid))]
internal partial struct StructAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}
"""))
.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 AbstractAuditLog", combined);
ScenarioExpect.Contains("Create()", combined);
ScenarioExpect.Contains("Create(\"audit-log\", SelectKey).Build()", combined);
ScenarioExpect.Contains("public sealed partial class SealedAuditLog", combined);
ScenarioExpect.Contains("Create(\"tenant\\\\\\\"audit\", SelectKey).Build()", combined);
ScenarioExpect.Contains("internal partial struct StructAuditLog", combined);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
})
.AssertPassed();

[Scenario("Generator emits nested audit log host wrappers")]
[Fact]
public Task Generator_Emits_Nested_Audit_Log_Host_Wrappers()
=> Given("nested audit log declarations with non-public accessibility", () => Compile("""
using System;
using PatternKit.Generators.AuditLog;

namespace Demo;

public sealed record AuditEntry(Guid EntryId);

public partial class AuditContainer
{
private partial class PrivateHost
{
[GenerateAuditLog(typeof(AuditEntry), typeof(Guid))]
protected partial class ProtectedAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid))]
private protected partial class PrivateProtectedAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}

[GenerateAuditLog(typeof(AuditEntry), typeof(Guid))]
protected internal partial class ProtectedInternalAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}
}
}
"""))
.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 AuditContainer", combined);
ScenarioExpect.Contains("private partial class PrivateHost", combined);
ScenarioExpect.Contains("protected partial class ProtectedAuditLog", combined);
ScenarioExpect.Contains("private protected partial class PrivateProtectedAuditLog", combined);
ScenarioExpect.Contains("protected internal partial class ProtectedInternalAuditLog", combined);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
})
.AssertPassed();

[Scenario("Generator skips malformed audit log type arguments")]
[Theory]
[InlineData("null!", "typeof(Guid)")]
[InlineData("typeof(AuditEntry)", "null!")]
public Task Generator_Skips_Malformed_Audit_Log_Type_Arguments(string entryType, string keyType)
=> Given("an audit log declaration with a null type argument", () => Compile($$"""
using System;
using PatternKit.Generators.AuditLog;

public sealed record AuditEntry(Guid EntryId);

[GenerateAuditLog({{entryType}}, {{keyType}})]
public static partial class OrderAuditLog
{
[AuditLogKeySelector]
private static Guid SelectKey(AuditEntry entry) => entry.EntryId;
}
"""))
.Then("no source is generated", result =>
ScenarioExpect.Empty(result.GeneratedSources))
.AssertPassed();

private static GeneratorResult Compile(string source)
{
var compilation = RoslynTestHelpers.CreateCompilation(
Expand Down
Loading