diff --git a/src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs b/src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs index 90f79683..eb0add3d 100644 --- a/src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs +++ b/src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs @@ -77,6 +77,49 @@ private static string GenerateSource(INamedTypeSymbol type, string factoryMethod 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.ActivityTracking.ActivityTracker ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.ActivityTracking.ActivityTracker.Create(\"").Append(Escape(trackerName)).AppendLine("\").Build();"); + 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(); + 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 "); @@ -84,13 +127,7 @@ private static string GenerateSource(INamedTypeSymbol type, string factoryMethod 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.ActivityTracking.ActivityTracker ").Append(factoryMethodName).AppendLine("()"); - sb.Append(" => global::PatternKit.Application.ActivityTracking.ActivityTracker.Create(\"").Append(Escape(trackerName)).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/ActivityTrackerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ActivityTrackerGeneratorTests.cs index 114ebaba..161eb2f9 100644 --- a/test/PatternKit.Generators.Tests/ActivityTrackerGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/ActivityTrackerGeneratorTests.cs @@ -1,71 +1,156 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using PatternKit.Generators.ActivityTracking; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Generators.Tests; -public sealed class ActivityTrackerGeneratorTests +[Feature("Activity Tracker generator")] +public sealed partial class ActivityTrackerGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) { [Scenario("Generates activity tracker factory")] [Fact] - public void GeneratesActivityTrackerFactory() - { - var source = """ + public Task Generates_Activity_Tracker_Factory() + => Given("an activity tracker declaration", () => Compile(""" using PatternKit.Generators.ActivityTracking; namespace Demo; [GenerateActivityTracker(FactoryMethodName = "Build", TrackerName = "dashboard-loading")] public static partial class DashboardTracker; - """; - - var comp = CreateCompilation(source, nameof(GeneratesActivityTrackerFactory)); - _ = RoslynTestHelpers.Run(comp, new ActivityTrackerGenerator(), out var run, out var updated); - - ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); - var generated = ScenarioExpect.Single(run.Results.SelectMany(static result => result.GeneratedSources)); - var text = generated.SourceText.ToString(); - ScenarioExpect.Equal("DashboardTracker.ActivityTracker.g.cs", generated.HintName); - ScenarioExpect.Contains("Build()", text); - ScenarioExpect.Contains("ActivityTracker.Create(\"dashboard-loading\").Build()", text); - ScenarioExpect.True(updated.Emit(Stream.Null).Success); - } + """)) + .Then("the generated source creates the configured tracker", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class DashboardTracker", source); + ScenarioExpect.Contains("ActivityTracker Build()", source); + ScenarioExpect.Contains("ActivityTracker.Create(\"dashboard-loading\").Build()", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); - [Scenario("Reports activity tracker diagnostics")] + [Scenario("Reports diagnostic for non-partial activity tracker declarations")] + [Fact] + public Task Reports_Diagnostic_For_Non_Partial_Activity_Tracker_Declarations() + => Given("a non-partial activity tracker declaration", () => Compile(""" + using PatternKit.Generators.ActivityTracking; + + [GenerateActivityTracker] + public static class DashboardTracker; + """)) + .Then("the diagnostic identifies the host", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKAT001")) + .AssertPassed(); + + [Scenario("Reports diagnostic for invalid activity tracker configuration")] [Theory] - [InlineData("public static class DashboardTracker;", "PKAT001")] - [InlineData("public static partial class DashboardTracker;", "PKAT002")] - public void ReportsActivityTrackerDiagnostics(string declaration, string expected) - { - var invalidConfig = expected == "PKAT002" ? "TrackerName = \"\"" : ""; - var source = $$""" + [InlineData("FactoryMethodName = \"\"", "PKAT002")] + [InlineData("TrackerName = \"\"", "PKAT002")] + [InlineData("FactoryMethodName = \" \"", "PKAT002")] + [InlineData("TrackerName = \" \"", "PKAT002")] + public Task Reports_Diagnostic_For_Invalid_Activity_Tracker_Configuration(string invalidConfiguration, string expected) + => Given("an invalid activity tracker declaration", () => Compile($$""" + using PatternKit.Generators.ActivityTracking; + + [GenerateActivityTracker({{invalidConfiguration}})] + public static partial class DashboardTracker; + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == expected)) + .AssertPassed(); + + [Scenario("Generates activity tracker defaults and type shapes")] + [Fact] + public Task Generates_Activity_Tracker_Defaults_And_Type_Shapes() + => Given("activity tracker declarations using default names and different host shapes", () => Compile(""" using PatternKit.Generators.ActivityTracking; namespace Demo; - [GenerateActivityTracker({{invalidConfig}})] - {{declaration}} - """; + [GenerateActivityTracker] + internal abstract partial class AbstractTracker; - var comp = CreateCompilation(source, nameof(ReportsActivityTrackerDiagnostics) + expected); - _ = RoslynTestHelpers.Run(comp, new ActivityTrackerGenerator(), out var run, out _); + [GenerateActivityTracker(TrackerName = "tenant\\\"dashboard")] + public sealed partial class SealedTracker; - ScenarioExpect.Equal(expected, ScenarioExpect.Single(run.Results.SelectMany(static result => result.Diagnostics)).Id); - } + [GenerateActivityTracker] + internal partial struct StructTracker; + """)) + .Then("generated sources preserve host shape and configured names", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Equal(3, result.GeneratedSources.Count); - private static CSharpCompilation CreateCompilation(string source, string assemblyName) - => RoslynTestHelpers.CreateCompilation( + var combined = string.Join("\n", result.GeneratedSources); + ScenarioExpect.Contains("internal abstract partial class AbstractTracker", combined); + ScenarioExpect.Contains("Create()", combined); + ScenarioExpect.Contains("ActivityTracker.Create(\"activity-tracker\")", combined); + ScenarioExpect.Contains("public sealed partial class SealedTracker", combined); + ScenarioExpect.Contains("ActivityTracker.Create(\"tenant\\\\\\\"dashboard\")", combined); + ScenarioExpect.Contains("internal partial struct StructTracker", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generates nested activity tracker host wrappers")] + [Fact] + public Task Generates_Nested_Activity_Tracker_Host_Wrappers() + => Given("nested activity tracker declarations with non-public accessibility", () => Compile(""" + using PatternKit.Generators.ActivityTracking; + + namespace Demo; + + public partial class TrackerContainer + { + private partial class PrivateHost + { + [GenerateActivityTracker] + protected partial class ProtectedTracker; + + [GenerateActivityTracker] + private protected partial class PrivateProtectedTracker; + + [GenerateActivityTracker] + protected internal partial class ProtectedInternalTracker; + } + } + """)) + .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 TrackerContainer", combined); + ScenarioExpect.Contains("private partial class PrivateHost", combined); + ScenarioExpect.Contains("protected partial class ProtectedTracker", combined); + ScenarioExpect.Contains("private protected partial class PrivateProtectedTracker", combined); + ScenarioExpect.Contains("protected internal partial class ProtectedInternalTracker", combined); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( source, - assemblyName, - extra: - [ - MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), - MetadataReference.CreateFromFile(typeof(PatternKit.Application.ActivityTracking.ActivityTracker).Assembly.Location) - ]); - - private static string GetAbstractionsAssemblyPath() - => Path.Combine( - Path.GetDirectoryName(typeof(ActivityTrackerGenerator).Assembly.Location)!, - "PatternKit.Generators.Abstractions.dll"); + "ActivityTrackerGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.ActivityTracking.ActivityTracker).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new ActivityTrackerGenerator(), 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 Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); }