diff --git a/.gitignore b/.gitignore index fadc911..6bc23ae 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,10 @@ coverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# Verify snapshots (received files) +*.received.* + +# User-specific files +*.user \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8dc8ef3..5dfb3df 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -19,5 +20,8 @@ + + + \ No newline at end of file diff --git a/src/Endpointer.Generator.Tests/Endpointer.Generator.Tests.csproj b/src/Endpointer.Generator.Tests/Endpointer.Generator.Tests.csproj index 76027d8..17cf5ea 100644 --- a/src/Endpointer.Generator.Tests/Endpointer.Generator.Tests.csproj +++ b/src/Endpointer.Generator.Tests/Endpointer.Generator.Tests.csproj @@ -9,6 +9,9 @@ + + + diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithMultipleEndpoints_MatchesSnapshot.verified.txt b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithMultipleEndpoints_MatchesSnapshot.verified.txt new file mode 100644 index 0000000..e4c12ac --- /dev/null +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithMultipleEndpoints_MatchesSnapshot.verified.txt @@ -0,0 +1,36 @@ +[ +// +#nullable enable + +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Endpointer; + +public static class EndpointerExtensions +{ + public static IServiceCollection AddEndpointer(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IEndpointRouteBuilder MapEndpointer(this IEndpointRouteBuilder endpoints) + { + // App.Users.GetUserEndpoint + new App.Users.GetUserEndpoint.Endpoint().MapEndpoint(endpoints); + + // App.Users.CreateUserEndpoint + new App.Users.CreateUserEndpoint.Endpoint().MapEndpoint(endpoints); + + // App.Orders.GetOrderEndpoint + new App.Orders.GetOrderEndpoint.Endpoint().MapEndpoint(endpoints); + + return endpoints; + } +} + +] \ No newline at end of file diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithNoEndpoints_MatchesSnapshot.verified.txt b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithNoEndpoints_MatchesSnapshot.verified.txt new file mode 100644 index 0000000..9834d95 --- /dev/null +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithNoEndpoints_MatchesSnapshot.verified.txt @@ -0,0 +1,23 @@ +[ +// +#nullable enable + +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Endpointer; + +public static class EndpointerExtensions +{ + public static IServiceCollection AddEndpointer(this IServiceCollection services) + { + return services; + } + + public static IEndpointRouteBuilder MapEndpointer(this IEndpointRouteBuilder endpoints) + { + return endpoints; + } +} + +] \ No newline at end of file diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithSingleEndpoint_MatchesSnapshot.verified.txt b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithSingleEndpoint_MatchesSnapshot.verified.txt new file mode 100644 index 0000000..368af2a --- /dev/null +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.EndpointerRegistration_WithSingleEndpoint_MatchesSnapshot.verified.txt @@ -0,0 +1,28 @@ +[ +// +#nullable enable + +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Endpointer; + +public static class EndpointerExtensions +{ + public static IServiceCollection AddEndpointer(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } + + public static IEndpointRouteBuilder MapEndpointer(this IEndpointRouteBuilder endpoints) + { + // TestApp.GetTimeEndpoint + new TestApp.GetTimeEndpoint.Endpoint().MapEndpoint(endpoints); + + return endpoints; + } +} + +] \ No newline at end of file diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.IEndpointInterface_MatchesSnapshot.verified.txt b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.IEndpointInterface_MatchesSnapshot.verified.txt new file mode 100644 index 0000000..5b265b6 --- /dev/null +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.IEndpointInterface_MatchesSnapshot.verified.txt @@ -0,0 +1,16 @@ +[ +// +#nullable enable + +using Microsoft.AspNetCore.Routing; + +namespace Endpointer; + +/// +/// Marker interface for endpoint classes to be discovered by the source generator. +/// +public interface IEndpoint +{ + void MapEndpoint(IEndpointRouteBuilder endpoints); +} +] \ No newline at end of file diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.cs b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.cs new file mode 100644 index 0000000..775ba75 --- /dev/null +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorSnapshotTests.cs @@ -0,0 +1,149 @@ +using Basic.Reference.Assemblies; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Endpointer.Generator.Tests; + +public class EndpointerGeneratorSnapshotTests +{ + // Stub for ASP.NET Core types needed in test compilation + private const string AspNetCoreStubs = """ + namespace Microsoft.AspNetCore.Routing + { + public interface IEndpointRouteBuilder { } + } + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection { } + + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddScoped(this IServiceCollection services) => services; + } + } + """; + + [Test] + public Task IEndpointInterface_MatchesSnapshot() + { + var (result, _) = RunGenerator(""); + + var generatedSources = result.Results[0].GeneratedSources + .Where(s => string.Equals(s.HintName, "IEndpoint.g.cs", StringComparison.Ordinal)) + .Select(s => s.SourceText.ToString()); + + return Verify(generatedSources); + } + + [Test] + public Task EndpointerRegistration_WithNoEndpoints_MatchesSnapshot() + { + var (result, _) = RunGenerator(""); + + var generatedSources = result.Results[0].GeneratedSources + .Where(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)) + .Select(s => s.SourceText.ToString()); + + return Verify(generatedSources); + } + + [Test] + public Task EndpointerRegistration_WithSingleEndpoint_MatchesSnapshot() + { + const string source = """ + using Endpointer; + using Microsoft.AspNetCore.Routing; + + namespace TestApp; + + public class GetTimeEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + """; + + var (result, _) = RunGenerator(source); + + var generatedSources = result.Results[0].GeneratedSources + .Where(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)) + .Select(s => s.SourceText.ToString()); + + return Verify(generatedSources); + } + + [Test] + public Task EndpointerRegistration_WithMultipleEndpoints_MatchesSnapshot() + { + const string source = """ + using Endpointer; + using Microsoft.AspNetCore.Routing; + + namespace App.Users + { + public class GetUserEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + + public class CreateUserEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + } + + namespace App.Orders + { + public class GetOrderEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + } + """; + + var (result, _) = RunGenerator(source); + + var generatedSources = result.Results[0].GeneratedSources + .Where(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)) + .Select(s => s.SourceText.ToString()); + + return Verify(generatedSources); + } + + private static (GeneratorDriverRunResult Result, Compilation OutputCompilation) RunGenerator(string source) + { + var syntaxTrees = new List + { + CSharpSyntaxTree.ParseText(AspNetCoreStubs), + }; + + if (!string.IsNullOrEmpty(source)) + { + syntaxTrees.Add(CSharpSyntaxTree.ParseText(source)); + } + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: syntaxTrees, + references: Net80.References.All, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new EndpointerGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + + return (driver.GetRunResult(), outputCompilation); + } +} diff --git a/src/Endpointer.Generator.Tests/EndpointerGeneratorTests.cs b/src/Endpointer.Generator.Tests/EndpointerGeneratorTests.cs index b69ec60..d95fdc4 100644 --- a/src/Endpointer.Generator.Tests/EndpointerGeneratorTests.cs +++ b/src/Endpointer.Generator.Tests/EndpointerGeneratorTests.cs @@ -1,3 +1,4 @@ +using Basic.Reference.Assemblies; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -5,12 +6,27 @@ namespace Endpointer.Generator.Tests; public class EndpointerGeneratorTests { + // Stub for ASP.NET Core types needed in test compilation + private const string AspNetCoreStubs = """ + namespace Microsoft.AspNetCore.Routing + { + public interface IEndpointRouteBuilder { } + } + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection { } + + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddScoped(this IServiceCollection services) => services; + } + } + """; + [Test] public async Task Generator_EmitsIEndpointInterface() { - const string source = ""; - - var result = RunGenerator(source); + var (result, _) = RunGenerator(""); var iEndpointSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "IEndpoint.g.cs", StringComparison.Ordinal)); @@ -21,9 +37,7 @@ public async Task Generator_EmitsIEndpointInterface() [Test] public async Task Generator_EmitsMapEndpointerExtension() { - const string source = ""; - - var result = RunGenerator(source); + var (result, _) = RunGenerator(""); var registrationSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); @@ -34,9 +48,7 @@ public async Task Generator_EmitsMapEndpointerExtension() [Test] public async Task Generator_EmitsAddEndpointerExtension() { - const string source = ""; - - var result = RunGenerator(source); + var (result, _) = RunGenerator(""); var registrationSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); @@ -57,21 +69,24 @@ public class GetTimeEndpoint { public class Endpoint : IEndpoint { - public void MapEndpoint(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("/time", () => "ok"); - } + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } } } """; - var result = RunGenerator(source); + var (result, outputCompilation) = RunGenerator(source); var registrationSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); string generatedCode = registrationSource.SourceText.ToString(); await Assert.That(generatedCode).Contains("services.AddScoped();"); + + // Verify no compilation errors + var errors = outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + await Assert.That(errors).IsEmpty(); } [Test] @@ -87,15 +102,12 @@ public class GetTimeEndpoint { public class Endpoint : IEndpoint { - public void MapEndpoint(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("/time", () => "ok"); - } + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } } } """; - var result = RunGenerator(source); + var (result, _) = RunGenerator(source); var registrationSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); @@ -115,46 +127,176 @@ namespace TestApp; public class NotNestedEndpoint : IEndpoint { - public void MapEndpoint(IEndpointRouteBuilder endpoints) + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + """; + + var (result, _) = RunGenerator(source); + + var registrationSource = result.Results[0].GeneratedSources + .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); + + string generatedCode = registrationSource.SourceText.ToString(); + await Assert.That(generatedCode).DoesNotContain("NotNestedEndpoint"); + } + + [Test] + public async Task Generator_RegistersMultipleEndpoints() + { + const string source = """ + using Endpointer; + using Microsoft.AspNetCore.Routing; + + namespace TestApp; + + public class GetTimeEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + + public class CreateUserEndpoint + { + public class Endpoint : IEndpoint { - endpoints.MapGet("/test", () => "ok"); + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } } } """; - var result = RunGenerator(source); + var (result, outputCompilation) = RunGenerator(source); var registrationSource = result.Results[0].GeneratedSources .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); string generatedCode = registrationSource.SourceText.ToString(); - await Assert.That(generatedCode).DoesNotContain("NotNestedEndpoint"); + await Assert.That(generatedCode).Contains("services.AddScoped();"); + await Assert.That(generatedCode).Contains("services.AddScoped();"); + + // Verify no compilation errors + var errors = outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + await Assert.That(errors).IsEmpty(); } - private static GeneratorDriverRunResult RunGenerator(string source) + [Test] + public async Task Generator_HandlesEndpointsInDifferentNamespaces() { - var syntaxTree = CSharpSyntaxTree.ParseText(source); + const string source = """ + using Endpointer; + using Microsoft.AspNetCore.Routing; - var references = new List + namespace App.Users + { + public class GetUserEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + } + + namespace App.Orders + { + public class GetOrderEndpoint + { + public class Endpoint : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + } + """; + + var (result, outputCompilation) = RunGenerator(source); + + var registrationSource = result.Results[0].GeneratedSources + .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); + + string generatedCode = registrationSource.SourceText.ToString(); + await Assert.That(generatedCode).Contains("services.AddScoped();"); + await Assert.That(generatedCode).Contains("services.AddScoped();"); + + // Verify no compilation errors + var errors = outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task Generator_SupportsNonDefaultNestedClassName() + { + const string source = """ + using Endpointer; + using Microsoft.AspNetCore.Routing; + + namespace TestApp; + + public class GetTimeEndpoint + { + public class Handler : IEndpoint + { + public void MapEndpoint(IEndpointRouteBuilder endpoints) { } + } + } + """; + + var (result, outputCompilation) = RunGenerator(source); + + var registrationSource = result.Results[0].GeneratedSources + .First(s => string.Equals(s.HintName, "EndpointerRegistration.g.cs", StringComparison.Ordinal)); + + string generatedCode = registrationSource.SourceText.ToString(); + await Assert.That(generatedCode).Contains("services.AddScoped();"); + await Assert.That(generatedCode).Contains("new TestApp.GetTimeEndpoint.Handler()"); + + // Verify no compilation errors + var errors = outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task Generator_GeneratesCompilableCode_WithNoEndpoints() + { + var (_, outputCompilation) = RunGenerator(""); + + var errors = outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + await Assert.That(errors).IsEmpty(); + } + + private static (GeneratorDriverRunResult Result, Compilation OutputCompilation) RunGenerator(string source) + { + var syntaxTrees = new List { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + CSharpSyntaxTree.ParseText(AspNetCoreStubs), }; - // Add runtime assemblies - string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; - references.Add(MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll"))); + if (!string.IsNullOrEmpty(source)) + { + syntaxTrees.Add(CSharpSyntaxTree.ParseText(source)); + } var compilation = CSharpCompilation.Create( assemblyName: "TestAssembly", - syntaxTrees: [syntaxTree], - references: references, + syntaxTrees: syntaxTrees, + references: Net80.References.All, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var generator = new EndpointerGenerator(); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); - return driver.GetRunResult(); + return (driver.GetRunResult(), outputCompilation); } } diff --git a/src/Endpointer.Generator.Tests/ModuleInitializer.cs b/src/Endpointer.Generator.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..55567ee --- /dev/null +++ b/src/Endpointer.Generator.Tests/ModuleInitializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace Endpointer.Generator.Tests; + +internal static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + VerifySourceGenerators.Initialize(); + } +} diff --git a/src/Endpointer/SourceGenerationHelper.cs b/src/Endpointer/SourceGenerationHelper.cs index c24192b..1b478ea 100644 --- a/src/Endpointer/SourceGenerationHelper.cs +++ b/src/Endpointer/SourceGenerationHelper.cs @@ -58,7 +58,11 @@ private static void GenerateAddEndpointerMethod(StringBuilder sb, ImmutableArray sb.AppendLine($" services.AddScoped<{endpoint.FullyQualifiedOuterName}>();"); } - sb.AppendLine(); + if (endpoints.Length > 0) + { + sb.AppendLine(); + } + sb.AppendLine(" return services;"); sb.AppendLine(" }"); sb.AppendLine();