diff --git a/src/PatternKit.Generators/AuditLog/AuditLogGenerator.cs b/src/PatternKit.Generators/AuditLog/AuditLogGenerator.cs index eaa59d9a..f8b8c9d4 100644 --- a/src/PatternKit.Generators/AuditLog/AuditLogGenerator.cs +++ b/src/PatternKit.Generators/AuditLog/AuditLogGenerator.cs @@ -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)) @@ -108,6 +109,49 @@ 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(); + 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 "); @@ -115,14 +159,7 @@ private static string GenerateSource( 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); } private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/test/PatternKit.Generators.Tests/AuditLogGeneratorTests.cs b/test/PatternKit.Generators.Tests/AuditLogGeneratorTests.cs index 1b6e0d2e..e20f3cfa 100644 --- a/test/PatternKit.Generators.Tests/AuditLogGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AuditLogGeneratorTests.cs @@ -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 { @@ -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.Create(\"order-audit\", SelectKey).Build()", source); ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics); @@ -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(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($$""" @@ -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(