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
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,57 @@ 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<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.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("\"", "\\\"");
Expand Down
175 changes: 130 additions & 45 deletions test/PatternKit.Generators.Tests/ActivityTrackerGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> Diagnostics,
IReadOnlyList<string> GeneratedSources,
bool EmitSuccess,
IReadOnlyList<string> EmitDiagnostics);
}
Loading