diff --git a/.editorconfig b/.editorconfig index 013544f4..10752cb8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,23 +3,34 @@ root = true [*] +charset = utf-8 +end_of_line = lf indent_style = tab insert_final_newline = true # Build scripts [*.{yml,yaml}] -indent_style = spaces indent_size = 2 # XML project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_style = tab indent_size = 2 +# Verify settings + +[*.{received,verified}.{txt,xml,json,cs}] +indent_size = unset +indent_style = unset +insert_final_newline = false +tab_width = unset +trim_trailing_whitespace = false + # Code files [*.cs] indent_size = 4 tab_width = 4 -charset = utf-8-bom +max_line_length = 150 ## Dotnet code style settings: @@ -31,10 +42,10 @@ dotnet_separate_import_directive_groups = false dotnet_style_require_accessibility_modifiers = for_non_interface_members:error # Avoid "this." and "Me." if not necessary -dotnet_style_qualification_for_field = false:refactoring -dotnet_style_qualification_for_property = false:refactoring -dotnet_style_qualification_for_method = false:refactoring -dotnet_style_qualification_for_event = false:refactoring +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_property = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_event = false # Use language keywords instead of framework type names for type references dotnet_style_predefined_type_for_locals_parameters_members = true:error @@ -182,7 +193,7 @@ csharp_space_after_dot = false csharp_space_after_keywords_in_control_flow_statements = true csharp_space_after_semicolon_in_for_statement = true csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = do_not_ignore +csharp_space_around_declaration_statements = false csharp_space_before_colon_in_inheritance_clause = true csharp_space_before_comma = false csharp_space_before_dot = false @@ -200,8 +211,16 @@ csharp_space_between_square_brackets = false # Blocks are allowed csharp_prefer_braces = when_multiline:silent -csharp_preserve_single_line_blocks = true:silent -csharp_preserve_single_line_statements = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +# Resharper/Rider inspections +resharper_trailing_comma_in_multiline_lists = true +resharper_align_multiline_binary_expressions_chain = false +resharper_align_multiline_comments = false +resharper_align_multiline_statement_conditions = false +resharper_indent_raw_literal_string = do_not_change +resharper_csharp_max_line_length = 150 # Style Analytics dotnet_analyzer_diagnostic.category-Style.severity = warning @@ -215,6 +234,13 @@ dotnet_diagnostic.CS1712.severity = none # CS1712: Type parameter # Async dotnet_diagnostic.CS1998.severity = error # CS1998: Async method lacks 'await' operators and will run synchronously dotnet_diagnostic.CS4014.severity = error # CS4014: Because this call is not awaited, execution of the current method continues before the call is completed +dotnet_diagnostic.CA2007.severity = none # CA2007: Consider calling ConfigureAwait on the awaited task + +# Immediate.Handlers relies on nested types +dotnet_diagnostic.CA1034.severity = none # CA1034: Nested types should not be visible + +# No need for cryptographically secure anything in this project +dotnet_diagnostic.CA5394.severity = none # CA5394: Random is an insecure random number generator # Dispose things need disposing dotnet_diagnostic.CA2000.severity = error # CA2000: Dispose objects before losing scope diff --git a/.gitattributes b/.gitattributes index 6313b56c..176a458f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text=auto eol=lf +* text=auto diff --git a/.gitignore b/.gitignore index 482bf54b..168de5fe 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,11 @@ # VS Code cache/options directory .vscode/ +# Rider temporary files +.idea/ + # TestResults [Tt]est[Rr]esults/ + +# Verify +*.received.* diff --git a/Directory.Build.props b/Directory.Build.props index 62c6f907..319aa4c2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ latest + net8.0 enable $(WarningsAsErrors);nullable; @@ -11,6 +12,9 @@ true true + true + + false @@ -18,9 +22,6 @@ - - true - true true true diff --git a/Directory.Packages.props b/Directory.Packages.props index 74b6482e..c054b886 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,20 +1,33 @@ - - true - + + true + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Immediate.Handlers.sln b/Immediate.Handlers.sln index 47a1d413..ca931b87 100644 --- a/Immediate.Handlers.sln +++ b/Immediate.Handlers.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -27,12 +27,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{C484 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers", "src\Immediate.Handlers\Immediate.Handlers.csproj", "{4E9DC4CB-4833-4471-AF34-699565BCFDBE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.Utility", "src\Immediate.Handlers.Utility\Immediate.Handlers.Utility.csproj", "{789B0C8D-98ED-4712-8AB1-E4B10AD4D669}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.Shared", "src\Immediate.Handlers.Shared\Immediate.Handlers.Shared.csproj", "{789B0C8D-98ED-4712-8AB1-E4B10AD4D669}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.Tests", "tests\Immediate.Handlers.Tests\Immediate.Handlers.Tests.csproj", "{7E5E76EB-8087-48F6-8A87-CFABFC5CB409}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Normal", "samples\Normal\Normal.csproj", "{4E8FA4AC-7802-4775-AF8B-781B199EE5E6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.FunctionalTests", "tests\Immediate.Handlers.FunctionalTests\Immediate.Handlers.FunctionalTests.csproj", "{DB656BD6-AFA3-4285-8CD6-4EA91DB36D65}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.CodeFixes", "src\Immediate.Handlers.CodeFixes\Immediate.Handlers.CodeFixes.csproj", "{0A786D44-F49E-46F6-A97E-9E8B75A12829}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.Analyzers", "src\Immediate.Handlers.Analyzers\Immediate.Handlers.Analyzers.csproj", "{E9AF88B6-8719-4FDD-B99E-F060BB116073}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Immediate.Handlers.Generators", "src\Immediate.Handlers.Generators\Immediate.Handlers.Generators.csproj", "{848CB147-CB80-448E-A1B1-30231A4F843C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +63,22 @@ Global {4E8FA4AC-7802-4775-AF8B-781B199EE5E6}.Debug|Any CPU.Build.0 = Debug|Any CPU {4E8FA4AC-7802-4775-AF8B-781B199EE5E6}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E8FA4AC-7802-4775-AF8B-781B199EE5E6}.Release|Any CPU.Build.0 = Release|Any CPU + {DB656BD6-AFA3-4285-8CD6-4EA91DB36D65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB656BD6-AFA3-4285-8CD6-4EA91DB36D65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB656BD6-AFA3-4285-8CD6-4EA91DB36D65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB656BD6-AFA3-4285-8CD6-4EA91DB36D65}.Release|Any CPU.Build.0 = Release|Any CPU + {0A786D44-F49E-46F6-A97E-9E8B75A12829}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A786D44-F49E-46F6-A97E-9E8B75A12829}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A786D44-F49E-46F6-A97E-9E8B75A12829}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A786D44-F49E-46F6-A97E-9E8B75A12829}.Release|Any CPU.Build.0 = Release|Any CPU + {E9AF88B6-8719-4FDD-B99E-F060BB116073}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9AF88B6-8719-4FDD-B99E-F060BB116073}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9AF88B6-8719-4FDD-B99E-F060BB116073}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9AF88B6-8719-4FDD-B99E-F060BB116073}.Release|Any CPU.Build.0 = Release|Any CPU + {848CB147-CB80-448E-A1B1-30231A4F843C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {848CB147-CB80-448E-A1B1-30231A4F843C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {848CB147-CB80-448E-A1B1-30231A4F843C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {848CB147-CB80-448E-A1B1-30231A4F843C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -65,6 +89,10 @@ Global {789B0C8D-98ED-4712-8AB1-E4B10AD4D669} = {48400D35-471A-4EF4-93E4-2394D87E0012} {7E5E76EB-8087-48F6-8A87-CFABFC5CB409} = {517768B6-A2B3-4ED2-8F70-E53C85A493FF} {4E8FA4AC-7802-4775-AF8B-781B199EE5E6} = {C484CFC6-6DB0-48CB-A769-1A06FF40787E} + {DB656BD6-AFA3-4285-8CD6-4EA91DB36D65} = {517768B6-A2B3-4ED2-8F70-E53C85A493FF} + {0A786D44-F49E-46F6-A97E-9E8B75A12829} = {48400D35-471A-4EF4-93E4-2394D87E0012} + {E9AF88B6-8719-4FDD-B99E-F060BB116073} = {48400D35-471A-4EF4-93E4-2394D87E0012} + {848CB147-CB80-448E-A1B1-30231A4F843C} = {48400D35-471A-4EF4-93E4-2394D87E0012} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {184E3CAB-91E8-4F1A-A16B-C062F25BECC4} diff --git a/Immediate.Handlers.sln.DotSettings b/Immediate.Handlers.sln.DotSettings new file mode 100644 index 00000000..730553be --- /dev/null +++ b/Immediate.Handlers.sln.DotSettings @@ -0,0 +1,3 @@ + + DO_NOT_SHOW + True \ No newline at end of file diff --git a/readme.md b/readme.md index eef4400b..227d4246 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,139 @@ # Immediate.Handlers -| Name | Status | History | -| :--- | :--- | :--- | -| GitHub Actions | ![Build](https://github.com/viceroypenguin/Immediate.Handlers/actions/workflows/build.yml/badge.svg) | [![GitHub Actions Build History](https://buildstats.info/github/chart/viceroypenguin/Immediate.Handlers?branch=master&includeBuildsFromPullRequest=false)](https://github.com/viceroypenguin/Immediate.Handlers/actions) | [![NuGet](https://img.shields.io/nuget/v/Immediate.Handlers.svg?style=plastic)](https://www.nuget.org/packages/Immediate.Handlers/) [![GitHub release](https://img.shields.io/github/release/viceroypenguin/Immediate.Handlers.svg)](https://GitHub.com/viceroypenguin/Immediate.Handlers/releases/) [![GitHub license](https://img.shields.io/github/license/viceroypenguin/Immediate.Handlers.svg)](https://github.com/viceroypenguin/Immediate.Handlers/blob/master/license.txt) [![GitHub issues](https://img.shields.io/github/issues/viceroypenguin/Immediate.Handlers.svg)](https://GitHub.com/viceroypenguin/Immediate.Handlers/issues/) [![GitHub issues-closed](https://img.shields.io/github/issues-closed/viceroypenguin/Immediate.Handlers.svg)](https://GitHub.com/viceroypenguin/Immediate.Handlers/issues?q=is%3Aissue+is%3Aclosed) +[![GitHub Actions](https://github.com/viceroypenguin/Immediate.Handlers/actions/workflows/build.yml/badge.svg)](https://github.com/viceroypenguin/Immediate.Handlers/actions) --- +Immediate.Handlers is an implementation of the mediator pattern in .NET using source-generation. All pipeline behaviors +are determined and the call-tree built at compile-time; meaning that all dependencies are enforced via compile-time +safety checks. Behaviors and dependencies are obtained via DI at runtime based on compile-time determined dependencies. + +#### Examples +* Minimal Api: [Normal](./samples/Normal) + +## Installing Immediate.Handlers + +You can install [Immediate.Handlers with NuGet](https://www.nuget.org/packages/Immediate.Handlers): + + Install-Package Immediate.Handlers + +Or via the .NET Core command line interface: + + dotnet add package Immediate.Handlers + +Either commands, from Package Manager Console or .NET Core CLI, will download and install Immediate.Handlers. + +## Using Immediate.Handlers +### Creating Handlers + +Create a Handler by adding the following code: + +```cs +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} +``` + +This will automatically create a new class, `GetUsersQuery.Handler`, which encapsulates the following: +* attaching any behaviors defined for all queries in the assembly +* using a class to receive any DI services, such as `UsersService` + +Any consumer can now do the following: +```cs +public class Consumer(GetUsersQuery.Handler handler) +{ + public async Task Consumer(CancellationToken token) + { + var response = await handler.HandleAsync(new(), token); + // do something with response + } +} +``` + +### Creating Behaviors + +Create a behavior by implementing the `Immediate.Handlers.Shared.Behaviors<,>` class, as so: +```cs +public sealed class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + logger.LogInformation("LoggingBehavior.Enter"); + var response = await Next(request, cancellationToken); + logger.LogInformation("LoggingBehavior.Exit"); + return response; + } +} +``` + +This can be registered assembly-wide using: +```cs +[assembly: Behaviors( + typeof(LoggingBehavior<,>) +)] +``` + +or on an individual handler using: +```cs +[Handler] +[Behavior( + typeof(LoggingBehavior<,>) +)] +public static class GetUsersQuery +{ + // .. +} +``` + +Once added to the pipeline, the behavior will be called as part of the pipeline to handle a request. + +Note: adding a `[Behavior]` attribute to a handler will disregard all assembly-wide behaviors for that handler, so any +global behaviors necessary must be independently added to the handler override behaviors list. + +#### Behavior Constraints + +A constraint can be added to a behavior by using: +```cs +public sealed class LoggingBehavior + : Behavior + where TRequest : IRequestConstraint + where TResponse : IResponseConstraint +``` + +When a pipeline is generated, all potential behaviors are evaluated against the request and response types, and if +either type does not match a given constraint, the behavior is not added to the generated pipeline. + +### Registering with `IServiceCollection` + +Immediate.Handlers supports `Microsoft.Extensions.DependencyInjection.Abstractions` directly. + +#### Registering Handlers + +```cs +services.AddHandlers(); +``` + +This registers all classes in the assembly marked with `[Handler]`. + +#### Registering Behaviors + +```cs +services.AddBehaviors(); +``` + +This registers all behaviors referenced in any `[Behaviors]` attribute. diff --git a/samples/Normal/Behaviors.cs b/samples/Normal/Behaviors.cs new file mode 100644 index 00000000..24bca9a7 --- /dev/null +++ b/samples/Normal/Behaviors.cs @@ -0,0 +1,22 @@ +using Immediate.Handlers.Shared; +using Normal; + +[assembly: RenderMode(renderMode: RenderMode.Normal)] + +[assembly: Behaviors( + typeof(LoggingBehavior<,>) +)] + +namespace Normal; + +public class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + _ = logger.ToString(); + var response = await Next(request, cancellationToken); + + return response; + } +} diff --git a/samples/Normal/GetWeatherForecast.cs b/samples/Normal/GetWeatherForecast.cs new file mode 100644 index 00000000..5f4b7857 --- /dev/null +++ b/samples/Normal/GetWeatherForecast.cs @@ -0,0 +1,48 @@ +using Immediate.Handlers.Shared; + +namespace Normal; + +[Handler] +public static partial class GetWeatherForecast +{ + private static readonly string[] Summaries = + [ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching", + ]; + + public record Query; + + public record Response(DateOnly Date, int TemperatureC, string? Summary) + { + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } + + private static Task> HandleAsync( + Query _, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); + + var forecast = Enumerable.Range(1, 5) + .Select(index => + new Response + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + Summaries[Random.Shared.Next(Summaries.Length)] + ) + ); + + return Task.FromResult(forecast); + } +} diff --git a/samples/Normal/Normal.csproj b/samples/Normal/Normal.csproj index 397d8c21..ee69d8a7 100644 --- a/samples/Normal/Normal.csproj +++ b/samples/Normal/Normal.csproj @@ -1,12 +1,16 @@ - + - - net6.0;net8.0 - false - + + + + + + - + + + diff --git a/samples/Normal/Program.cs b/samples/Normal/Program.cs new file mode 100644 index 00000000..87c25d94 --- /dev/null +++ b/samples/Normal/Program.cs @@ -0,0 +1,27 @@ +using Normal; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddBehaviors(); +builder.Services.AddHandlers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + _ = app.UseSwagger(); + _ = app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapGet( + "/weatherforecast", + async (GetWeatherForecast.Handler handler, [AsParameters] GetWeatherForecast.Query query) => await handler.HandleAsync(query) + ) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); diff --git a/samples/Normal/Properties/launchSettings.json b/samples/Normal/Properties/launchSettings.json new file mode 100644 index 00000000..7b326d28 --- /dev/null +++ b/samples/Normal/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:33555", + "sslPort": 44332 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5172", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7288;http://localhost:5172", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Immediate.Handlers.Analyzers/AnalyzerReleases.Shipped.md b/src/Immediate.Handlers.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..e69de29b diff --git a/src/Immediate.Handlers.Analyzers/AnalyzerReleases.Unshipped.md b/src/Immediate.Handlers.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..dd0f3330 --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,16 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +IHR0001 | ImmediateHandler | Error | HandlerClassAnalyzer +IHR0002 | ImmediateHandler | Error | HandlerClassAnalyzer +IHR0003 | ImmediateHandler | Error | HandlerClassAnalyzer +IHR0004 | ImmediateHandler | Error | RenderModeAnalyzer +IHR0005 | ImmediateHandler | Error | HandlerClassAnalyzer +IHR0006 | ImmediateHandler | Error | BehaviorsAnalyzer +IHR0007 | ImmediateHandler | Error | BehaviorsAnalyzer +IHR0008 | ImmediateHandler | Error | BehaviorsAnalyzer +IHR0009 | ImmediateHandler | Warning | HandlerClassAnalyzer diff --git a/src/Immediate.Handlers.Analyzers/BehaviorsAnalyzer.cs b/src/Immediate.Handlers.Analyzers/BehaviorsAnalyzer.cs new file mode 100644 index 00000000..3eca3279 --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/BehaviorsAnalyzer.cs @@ -0,0 +1,157 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Immediate.Handlers.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class BehaviorsAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor BehaviorsMustInheritFromBehavior = + new( + id: DiagnosticIds.IHR0006BehaviorsMustInheritFromBehavior, + title: "Behaviors must inherit from Behavior<,>", + messageFormat: "Behavior type '{0}' must inherit from Behavior<,>", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "All Behaviors must inherit from Behavior<,>." + ); + + private static readonly DiagnosticDescriptor BehaviorsMustHaveTwoGenericParameters = + new( + id: DiagnosticIds.IHR0007BehaviorsMustHaveTwoGenericParameters, + title: "Behaviors must have two generic parameters", + messageFormat: "Behavior type '{0}' must have two generic parameters", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "All Behaviors must have two generic parameters, correctly referencing `TRequest` and `TResponse`." + ); + + private static readonly DiagnosticDescriptor BehaviorsMustUseUnboundGenerics = + new( + id: DiagnosticIds.IHR0008BehaviorsMustUseUnboundGenerics, + title: "Behaviors must use unbound generics", + messageFormat: "Behavior type '{0}' must be a generic type without type arguments", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "All behaviors must use a generic type without type arguments." + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + [ + BehaviorsMustInheritFromBehavior, + BehaviorsMustHaveTwoGenericParameters, + BehaviorsMustUseUnboundGenerics, + ]); + + public override void Initialize(AnalysisContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeOperation, OperationKind.Attribute); + } + + private void AnalyzeOperation(OperationAnalysisContext context) + { + var token = context.CancellationToken; + token.ThrowIfCancellationRequested(); + + if (context.Operation is not IAttributeOperation { Operation: IObjectCreationOperation attribute }) + return; + + var behaviorsAttributeSymbol = context.Compilation.GetTypeByMetadataName("Immediate.Handlers.Shared.BehaviorsAttribute"); + if (behaviorsAttributeSymbol is null) + return; + + token.ThrowIfCancellationRequested(); + if (!SymbolEqualityComparer.Default.Equals(attribute.Type?.OriginalDefinition, behaviorsAttributeSymbol)) + return; + + if (attribute.Arguments.Length != 1) + { + // note: this will already be a compiler error anyway + return; + } + + token.ThrowIfCancellationRequested(); + var array = attribute.Arguments[0].Value; + + var compilation = context.Compilation; + var arrayTypeSymbol = compilation.CreateArrayTypeSymbol(compilation.GetTypeByMetadataName("System.Type")!, 1); + if (!SymbolEqualityComparer.Default.Equals(array.Type, arrayTypeSymbol) + || array.ChildOperations.Count != 2 + || array.ChildOperations.ElementAt(1) is not IArrayInitializerOperation aio) + { + // note: this will already be a compiler error anyway + return; + } + + token.ThrowIfCancellationRequested(); + var baseBehaviorSymbol = context.Compilation.GetTypeByMetadataName("Immediate.Handlers.Shared.Behavior`2"); + if (baseBehaviorSymbol is null) + return; + + foreach (var op in aio.ChildOperations) + { + token.ThrowIfCancellationRequested(); + if (op is not ITypeOfOperation + { + TypeOperand: INamedTypeSymbol behaviorType, + Syntax: TypeOfExpressionSyntax toes, + } + ) + { + continue; + } + + var location = toes.Type.GetLocation(); + var originalDefinition = behaviorType.OriginalDefinition; + + if (!ImplementsBaseClass(originalDefinition, baseBehaviorSymbol)) + { + context.ReportDiagnostic( + Diagnostic.Create( + BehaviorsMustInheritFromBehavior, + location, + originalDefinition.Name) + ); + } + + if (!originalDefinition.IsGenericType + || originalDefinition.TypeParameters.Length != 2) + { + context.ReportDiagnostic( + Diagnostic.Create( + BehaviorsMustHaveTwoGenericParameters, + location, + originalDefinition.Name) + ); + } + else if (!behaviorType.IsUnboundGenericType) + { + context.ReportDiagnostic( + Diagnostic.Create( + BehaviorsMustUseUnboundGenerics, + location, + originalDefinition.Name) + ); + } + } + } + + private static bool ImplementsBaseClass(INamedTypeSymbol typeSymbol, INamedTypeSymbol typeToCheck) => + SymbolEqualityComparer.Default.Equals(typeSymbol, typeToCheck) + || (typeSymbol.BaseType is not null + && ImplementsBaseClass(typeSymbol.BaseType.OriginalDefinition, typeToCheck) + ); +} diff --git a/src/Immediate.Handlers.Analyzers/DiagnosticIds.cs b/src/Immediate.Handlers.Analyzers/DiagnosticIds.cs new file mode 100644 index 00000000..6a5ec0aa --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/DiagnosticIds.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Handlers.Analyzers; + +[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Diagnostic IDs start with IHR")] +internal static class DiagnosticIds +{ + public const string IHR0001HandlerMethodMustExist = "IHR0001"; + public const string IHR0002HandlerMethodMustReturnTask = "IHR0002"; + public const string IHR0003HandlerMethodMustReceiveCorrectParameters = "IHR0003"; + public const string IHR0004InvalidRenderMode = "IHR0004"; + public const string IHR0005HandlerClassMustNotBeNested = "IHR0005"; + public const string IHR0006BehaviorsMustInheritFromBehavior = "IHR0006"; + public const string IHR0007BehaviorsMustHaveTwoGenericParameters = "IHR0007"; + public const string IHR0008BehaviorsMustUseUnboundGenerics = "IHR0008"; + public const string IHR0009HandlerClassShouldDefineCommandOrQuery = "IHR0009"; +} diff --git a/src/Immediate.Handlers.Analyzers/HandlerClassAnalyzer.cs b/src/Immediate.Handlers.Analyzers/HandlerClassAnalyzer.cs new file mode 100644 index 00000000..3a76a629 --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/HandlerClassAnalyzer.cs @@ -0,0 +1,170 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Immediate.Handlers.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class HandlerClassAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor HandlerMethodMustExist = + new( + id: DiagnosticIds.IHR0001HandlerMethodMustExist, + title: "Handler type should implement a Handle/HandleAsync method", + messageFormat: "Handler type '{0}' should implement a Handle/HandleAsync method", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Classes annotated with a Handler attribute should implement a Handle/HandleAsync method." + ); + + private static readonly DiagnosticDescriptor HandlerMethodMustReturnTask = + new( + id: DiagnosticIds.IHR0002HandlerMethodMustReturnTask, + title: "Handler method must return a Task", + messageFormat: "Method '{0}' must return a Task", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Handler methods must return a Task." + ); + + private static readonly DiagnosticDescriptor HandlerMethodMustReceiveCorrectParameters = + new( + id: DiagnosticIds.IHR0003HandlerMethodMustReceiveCorrectParameters, + title: "Handler method must receive correct parameters", + messageFormat: "Method '{0}' must receive the request (ending with Command or Query) and a CancellationToken", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Handler method must must take the request type as its first parameter and a CancellationToken as its last parameter." + ); + + private static readonly DiagnosticDescriptor HandlerMustNotBeNestedInAnotherClass = + new( + id: DiagnosticIds.IHR0005HandlerClassMustNotBeNested, + title: "Handler nesting is not allowed", + messageFormat: "Handler '{0}' must not be nested in another type", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Handler class must not be nested in another type." + ); + + private static readonly DiagnosticDescriptor HandlerClassShouldDefineCommandOrQuery = + new( + id: DiagnosticIds.IHR0009HandlerClassShouldDefineCommandOrQuery, + title: "Handler class should define a Command or Query type", + messageFormat: "Handler '{0}' should define a Command or Query type", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Handler class should define a Command or Query type." + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + [ + HandlerMethodMustExist, + HandlerMethodMustReturnTask, + HandlerMethodMustReceiveCorrectParameters, + HandlerMustNotBeNestedInAnotherClass, + HandlerClassShouldDefineCommandOrQuery, + ]); + + public override void Initialize(AnalysisContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType); + } + + private static void AnalyzeSymbol(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol namedTypeSymbol) + return; + + if (!namedTypeSymbol + .GetAttributes() + .Any(x => x.AttributeClass?.ToString() == "Immediate.Handlers.Shared.HandlerAttribute") + ) + { + return; + } + + if (namedTypeSymbol.ContainingType is not null) + { + var mustNotBeWrappedInAnotherType = Diagnostic.Create( + HandlerMustNotBeNestedInAnotherClass, + namedTypeSymbol.Locations[0], + namedTypeSymbol.Name + ); + + context.ReportDiagnostic(mustNotBeWrappedInAnotherType); + } + + if (namedTypeSymbol.GetMembers() + .OfType() + .FirstOrDefault(x => + x.Name.EndsWith("Query", StringComparison.Ordinal) + || x.Name.EndsWith("Command", StringComparison.Ordinal) + ) is null + ) + { + var mustDefineCommandOrQueryDiagnostic = Diagnostic.Create( + HandlerClassShouldDefineCommandOrQuery, + namedTypeSymbol.Locations[0], + namedTypeSymbol.Name + ); + + context.ReportDiagnostic(mustDefineCommandOrQueryDiagnostic); + } + + if (namedTypeSymbol + .GetMembers() + .OfType() + .FirstOrDefault(x => x.Name is "Handle" or "HandleAsync") + is not { } methodSymbol) + { + var doesNotExistDiagnostic = Diagnostic.Create( + HandlerMethodMustExist, + namedTypeSymbol.Locations[0], + namedTypeSymbol.Name + ); + + context.ReportDiagnostic(doesNotExistDiagnostic); + return; + } + + if (methodSymbol.ReturnType is INamedTypeSymbol returnTypeSymbol + && returnTypeSymbol.ConstructedFrom.ToString() is not "System.Threading.Tasks.Task") + { + var mustReturnTaskT = Diagnostic.Create( + HandlerMethodMustReturnTask, + methodSymbol.Locations[0], + methodSymbol.Name + ); + + context.ReportDiagnostic(mustReturnTaskT); + } + + var methodSymbolParams = methodSymbol.Parameters; + if (methodSymbolParams.Length < 2 + || (!methodSymbolParams[0].Type.Name.EndsWith("Query", StringComparison.Ordinal) + && !methodSymbolParams[0].Type.Name.EndsWith("Command", StringComparison.Ordinal)) + || methodSymbolParams.Last().Type.ToString() is not "System.Threading.CancellationToken") + { + var mustHaveTwoParameters = Diagnostic.Create( + HandlerMethodMustReceiveCorrectParameters, + methodSymbol.Locations[0], + methodSymbol.Name + ); + + context.ReportDiagnostic(mustHaveTwoParameters); + } + } +} diff --git a/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.csproj b/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.csproj new file mode 100644 index 00000000..6675216e --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + true + true + + + + + + + + diff --git a/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.md b/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.md new file mode 100644 index 00000000..99582bde --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/Immediate.Handlers.Analyzers.md @@ -0,0 +1,116 @@ +# Immediate.Handlers.Analyzers + +## IHR0001: Handler method must exist + +Handler classes must define a method with the signature `private static Task HandleAsync(TRequest command, CancellationToken)`. + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Error | +| CodeFix | True | +--- + +## IHR0002: Handler method must return Task + +Handler methods must return a `Task` + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Error | +| CodeFix | False | +--- + +## IHR0003: Handler method must receive correct parameters + +Handler methods must receive a `TRequest` and a `CancellationToken` as parameters. Any services can be injected automatically +by passing them in as parameters to the `HandleAsync` method of the handler class, between the `TRequest` and `CancellationToken` parameters. + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Error | +| CodeFix | False | +--- + +## IHR0004: RenderMode attribute must be set to a valid RenderMode + +An invalid value on the `RenderMode` attribute on the assembly or a Handler is unsupported and will lead to compilation +errors. Removing the attribute will restore the default of `RenderMode.Normal`, or setting the value to a valid value +will correct the issue. + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Error | +| CodeFix | False | +--- + +## IHR0005: Handler class must not be nested in another type + +Nesting the handler class within another type is unsupported, since it creates difficulties with scoping on the source +generated side. While it would technically be possible in certain circumstances (containing type being partial e.g.) +it is not supported for now. + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Error | +| CodeFix | False | +--- + +## IHR0009: Handler class should define a Command or Query record + +Handler classes should define a Command or Query record, which will be used as the request type for the handler. + +| Item | Value | +|----------|------------------| +| Category | ImmediateHandler | +| Enabled | True | +| Severity | Warning | +| CodeFix | True | +--- + +## IHR0006: Behaviors must inherit from `Behavior<,>` + +In order to be properly called as part of a pipeline, a behavior must inherit from the `Behavior<,>` class. + +|Item|Value| +|-|-| +|Category|ImmediateHandler| +|Enabled|True| +|Severity|Error| +|CodeFix|False| +--- + +## IHR0007: Behaviors must have two generic types + +All behaviors must have two generic parameters, for `TRequest` and `TResponse`. Without these parameters, it is not +possible to bind the behavior to the target request and response types. + +|Item|Value| +|-|-| +|Category|ImmediateHandler| +|Enabled|True| +|Severity|Error| +|CodeFix|False| +--- + +## IHR0008: Behavior must be referenced with unbound generic + +Behaviors must be referenced using the unbound generic syntax. Referencing a generic type using a specific type will +introduce inconsistencies in connecting multiple behaviors in a pipeline. + +|Item|Value| +|-|-| +|Category|ImmediateHandler| +|Enabled|True| +|Severity|Error| +|CodeFix|False| +--- diff --git a/src/Immediate.Handlers.Analyzers/Properties/launchSettings.json b/src/Immediate.Handlers.Analyzers/Properties/launchSettings.json new file mode 100644 index 00000000..b19d2a16 --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Normal": { + "commandName": "DebugRoslynComponent", + "targetProject": "..//..//Samples//Normal//Normal.csproj" + } + } +} diff --git a/src/Immediate.Handlers.Analyzers/RenderModeAnalyzer.cs b/src/Immediate.Handlers.Analyzers/RenderModeAnalyzer.cs new file mode 100644 index 00000000..b421e713 --- /dev/null +++ b/src/Immediate.Handlers.Analyzers/RenderModeAnalyzer.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Immediate.Handlers.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RenderModeAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor InvalidRenderMode = + new( + id: DiagnosticIds.IHR0004InvalidRenderMode, + title: "RenderMode attribute must be set to a valid RenderMode", + messageFormat: "RenderMode attribute is set to an invalid RenderMode", + category: "ImmediateHandler", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "RenderMode attribute must be set to a valid RenderMode (`RenderMode.Normal`) or removed to generate handlers." + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + [ + InvalidRenderMode, + ]); + + public override void Initialize(AnalysisContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeOperation, OperationKind.Attribute); + } + + private void AnalyzeOperation(OperationAnalysisContext context) + { + var token = context.CancellationToken; + token.ThrowIfCancellationRequested(); + + if (context.Operation is not IAttributeOperation { Operation: IObjectCreationOperation attribute }) + return; + + var renderModeAttributeSymbol = context.Compilation.GetTypeByMetadataName("Immediate.Handlers.Shared.RenderModeAttribute"); + + token.ThrowIfCancellationRequested(); + if (!SymbolEqualityComparer.Default.Equals(attribute.Type?.OriginalDefinition, renderModeAttributeSymbol)) + return; + + token.ThrowIfCancellationRequested(); + var renderModeSymbol = context.Compilation.GetTypeByMetadataName("Immediate.Handlers.Shared.RenderMode"); + + if (attribute.Arguments.Length != 1 + || attribute.Arguments[0].Value is not IFieldReferenceOperation value + || !SymbolEqualityComparer.Default.Equals(value.Type?.OriginalDefinition, renderModeSymbol) + || value.Member.Name is "None") + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidRenderMode, + attribute.Syntax.GetLocation(), + "" + )); + } + } +} diff --git a/src/Immediate.Handlers.CodeFixes/AnalyzerReleases.Shipped.md b/src/Immediate.Handlers.CodeFixes/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..e69de29b diff --git a/src/Immediate.Handlers.CodeFixes/AnalyzerReleases.Unshipped.md b/src/Immediate.Handlers.CodeFixes/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..17d4678c --- /dev/null +++ b/src/Immediate.Handlers.CodeFixes/AnalyzerReleases.Unshipped.md @@ -0,0 +1,2 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Immediate.Handlers.CodeFixes/HandlerClassShouldDefineCommandOrQueryCodeFixProvider.cs b/src/Immediate.Handlers.CodeFixes/HandlerClassShouldDefineCommandOrQueryCodeFixProvider.cs new file mode 100644 index 00000000..76ffac9e --- /dev/null +++ b/src/Immediate.Handlers.CodeFixes/HandlerClassShouldDefineCommandOrQueryCodeFixProvider.cs @@ -0,0 +1,78 @@ +using System.Collections.Immutable; +using Immediate.Handlers.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using SyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind; + +namespace Immediate.Handlers.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp)] +public class HandlerClassShouldDefineCommandOrQueryCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create([DiagnosticIds.IHR0009HandlerClassShouldDefineCommandOrQuery]); + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + // We link only one diagnostic and assume there is only one diagnostic in the context. + var diagnostic = context.Diagnostics.Single(); + + // 'SourceSpan' of 'Location' is the highlighted area. We're going to use this area to find the 'SyntaxNode' to rename. + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root?.FindNode(diagnosticSpan) is ClassDeclarationSyntax classDeclarationSyntax) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Add Query record", + createChangedDocument: _ => + AddCommandOrQueryRecordAsync(context.Document, root, classDeclarationSyntax, "Query"), + equivalenceKey: nameof(HandlerClassShouldDefineCommandOrQueryCodeFixProvider) + "Query" + ), + diagnostic); + + context.RegisterCodeFix( + CodeAction.Create( + title: "Add Command record", + createChangedDocument: _ => + AddCommandOrQueryRecordAsync(context.Document, root, classDeclarationSyntax, "Command"), + equivalenceKey: nameof(HandlerClassShouldDefineCommandOrQueryCodeFixProvider) + "Command" + ), + diagnostic); + } + } + + private static Task AddCommandOrQueryRecordAsync(Document document, + SyntaxNode root, + ClassDeclarationSyntax classDeclarationSyntax, + string recordName) + { + var recordDeclaration = RecordDeclaration( + SyntaxKind.RecordDeclaration, + Token(SyntaxKind.RecordKeyword), + Identifier(recordName)) + .WithModifiers( + TokenList( + Token(SyntaxKind.PublicKeyword))) + .WithSemicolonToken( + Token(SyntaxKind.SemicolonToken)) + .NormalizeWhitespace() + .WithAdditionalAnnotations(Formatter.Annotation); + + var newMembers = new SyntaxList() + .Add(recordDeclaration) + .AddRange(classDeclarationSyntax.Members); + + var newClassDecl = classDeclarationSyntax.WithMembers(newMembers); + + return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(classDeclarationSyntax, newClassDecl))); + } +} diff --git a/src/Immediate.Handlers.CodeFixes/HandlerMethodMustExistCodeFixProvider.cs b/src/Immediate.Handlers.CodeFixes/HandlerMethodMustExistCodeFixProvider.cs new file mode 100644 index 00000000..da92d01b --- /dev/null +++ b/src/Immediate.Handlers.CodeFixes/HandlerMethodMustExistCodeFixProvider.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using Immediate.Handlers.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using SyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind; + +namespace Immediate.Handlers.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp)] +public class HandlerMethodMustExistCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create([DiagnosticIds.IHR0001HandlerMethodMustExist]); + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + // We link only one diagnostic and assume there is only one diagnostic in the context. + var diagnostic = context.Diagnostics.Single(); + + // 'SourceSpan' of 'Location' is the highlighted area. We're going to use this area to find the 'SyntaxNode' to rename. + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root?.FindNode(diagnosticSpan) is ClassDeclarationSyntax classDeclarationSyntax) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Add HandleAsync method", + createChangedDocument: _ => AddHandleAsyncMethodAsync(context.Document, root, classDeclarationSyntax), + equivalenceKey: nameof(HandlerMethodMustExistCodeFixProvider) + ), + diagnostic); + } + } + + private static Task AddHandleAsyncMethodAsync(Document document, + SyntaxNode root, + ClassDeclarationSyntax classDeclarationSyntax) + { + var requestType = classDeclarationSyntax.Members + .OfType() + .FirstOrDefault(x => + x.Identifier.Text.EndsWith("Query", StringComparison.Ordinal) + || x.Identifier.Text.EndsWith("Command", StringComparison.Ordinal)); + + var methodDeclaration = MethodDeclaration( + GenericName( + Identifier("Task")) + .WithTypeArgumentList( + TypeArgumentList( + SingletonSeparatedList( + PredefinedType( + Token(SyntaxKind.IntKeyword))))), + Identifier("HandleAsync")) + .WithModifiers( + TokenList( + [ + Token(SyntaxKind.PrivateKeyword), + Token(SyntaxKind.StaticKeyword), + ])) + .WithParameterList( + ParameterList( + SeparatedList( + new SyntaxNodeOrToken[] + { + Parameter( + Identifier( + TriviaList(), + SyntaxKind.UnderscoreToken, + "_", + "_", + TriviaList())) + .WithType( + IdentifierName(requestType?.Identifier.Text ?? "object")), + Token(SyntaxKind.CommaToken), + Parameter( + Identifier("token")) + .WithType( + IdentifierName("CancellationToken")), + }))) + .WithBody( + Block( + SingletonList( + ReturnStatement( + LiteralExpression( + SyntaxKind.NullLiteralExpression))))) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newClassDecl = classDeclarationSyntax.AddMembers(methodDeclaration); + + return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(classDeclarationSyntax, newClassDecl))); + } +} diff --git a/src/Immediate.Handlers.CodeFixes/Immediate.Handlers.CodeFixes.csproj b/src/Immediate.Handlers.CodeFixes/Immediate.Handlers.CodeFixes.csproj new file mode 100644 index 00000000..573b312c --- /dev/null +++ b/src/Immediate.Handlers.CodeFixes/Immediate.Handlers.CodeFixes.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + true + true + + + + + + + + + + + + + diff --git a/src/Immediate.Handlers.CodeFixes/Properties/launchSettings.json b/src/Immediate.Handlers.CodeFixes/Properties/launchSettings.json new file mode 100644 index 00000000..b19d2a16 --- /dev/null +++ b/src/Immediate.Handlers.CodeFixes/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Normal": { + "commandName": "DebugRoslynComponent", + "targetProject": "..//..//Samples//Normal//Normal.csproj" + } + } +} diff --git a/src/Immediate.Handlers.Generators/DisplayNameFormatters.cs b/src/Immediate.Handlers.Generators/DisplayNameFormatters.cs new file mode 100644 index 00000000..7d035225 --- /dev/null +++ b/src/Immediate.Handlers.Generators/DisplayNameFormatters.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Generators; + +internal static class DisplayNameFormatters +{ + public static readonly SymbolDisplayFormat NonGenericFqdnFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.None // This excludes the generic type arguments + ); +} diff --git a/src/Immediate.Handlers.Generators/EquatableReadOnlyList.cs b/src/Immediate.Handlers.Generators/EquatableReadOnlyList.cs new file mode 100644 index 00000000..edf4d8f3 --- /dev/null +++ b/src/Immediate.Handlers.Generators/EquatableReadOnlyList.cs @@ -0,0 +1,53 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Handlers.Generators; + +[ExcludeFromCodeCoverage] +public static class EquatableReadOnlyList +{ + public static EquatableReadOnlyList ToEquatableReadOnlyList(this IEnumerable enumerable) + => new(enumerable.ToArray()); +} + +/// +/// A wrapper for IReadOnlyList that provides value equality support for the wrapped list. +/// +[ExcludeFromCodeCoverage] +public readonly struct EquatableReadOnlyList( + IReadOnlyList? collection +) : IEquatable>, IReadOnlyList +{ + private IReadOnlyList Collection => collection ?? []; + + public bool Equals(EquatableReadOnlyList other) + => this.SequenceEqual(other); + + public override bool Equals(object? obj) + => obj is EquatableReadOnlyList other && Equals(other); + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + foreach (var item in Collection) + hashCode.Add(item); + + return hashCode.ToHashCode(); + } + + IEnumerator IEnumerable.GetEnumerator() + => Collection.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => Collection.GetEnumerator(); + + public int Count => Collection.Count; + public T this[int index] => Collection[index]; + + public static bool operator ==(EquatableReadOnlyList left, EquatableReadOnlyList right) + => left.Equals(right); + + public static bool operator !=(EquatableReadOnlyList left, EquatableReadOnlyList right) + => !left.Equals(right); +} diff --git a/src/Immediate.Handlers.Generators/Immediate.Handlers.Generators.csproj b/src/Immediate.Handlers.Generators/Immediate.Handlers.Generators.csproj new file mode 100644 index 00000000..d5919157 --- /dev/null +++ b/src/Immediate.Handlers.Generators/Immediate.Handlers.Generators.csproj @@ -0,0 +1,36 @@ + + + + netstandard2.0 + true + true + + + + + + + + + + + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + diff --git a/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Entrypoint.cs b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Entrypoint.cs new file mode 100644 index 00000000..b62d6cfe --- /dev/null +++ b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Entrypoint.cs @@ -0,0 +1,194 @@ +using System.Collections.Immutable; +using System.Reflection; +using Immediate.Handlers.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Scriban; + +namespace Immediate.Handlers.Generators.ImmediateHandlers; + +[Generator] +public partial class ImmediateHandlersGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var hasMsDi = context + .MetadataReferencesProvider + .Collect() + .Select((refs, _) => refs + .Any(r => (r.Display ?? "") + .Contains("Microsoft.Extensions.DependencyInjection.Abstractions"))); + + var globalRenderMode = context.SyntaxProvider + .ForAttributeWithMetadataName( + "Immediate.Handlers.Shared.RenderModeAttribute", + (node, _) => node is CompilationUnitSyntax, + TransformRenderMode + ) + .Collect(); + + var behaviors = context.SyntaxProvider + .ForAttributeWithMetadataName( + "Immediate.Handlers.Shared.BehaviorsAttribute", + (node, _) => node is CompilationUnitSyntax, + TransformBehaviors + ) + .SelectMany((x, _) => x) + .Collect(); + + var handlers = context.SyntaxProvider + .ForAttributeWithMetadataName( + "Immediate.Handlers.Shared.HandlerAttribute", + predicate: (_, _) => true, + TransformHandler); + + var handlerNodes = handlers + .Combine(behaviors) + .Combine(globalRenderMode + .Combine(hasMsDi)); + + var template = GetTemplate("Handler"); + context.RegisterSourceOutput( + handlerNodes, + (spc, node) => RenderHandler( + spc, + handler: node.Left.Left, + behaviors: node.Left.Right, + renderModes: node.Right.Left, + hasMsDi: node.Right.Right, + template) + ); + + var registrationNodes = handlers + .Select((h, _) => (h?.DisplayName, h?.OverrideBehaviors)) + .Collect() + .Combine(behaviors) + .Combine(hasMsDi); + + context.RegisterSourceOutput( + registrationNodes, + (spc, node) => RenderServiceCollectionExtension( + spc, + handlers: node.Left.Left, + behaviors: node.Left.Right, + hasDi: node.Right) + ); + } + + private static void RenderServiceCollectionExtension( + SourceProductionContext context, + ImmutableArray<(string? displayName, EquatableReadOnlyList? behaviors)> handlers, + ImmutableArray behaviors, + bool hasDi + ) + { + var cancellationToken = context.CancellationToken; + cancellationToken.ThrowIfCancellationRequested(); + + if (!hasDi) + return; + + if (handlers.Any(h => h.displayName is null || (h.behaviors?.Any(b => b is null) ?? false))) + return; + + if (behaviors.Any(b => b is null)) + return; + + cancellationToken.ThrowIfCancellationRequested(); + var template = GetTemplate("ServiceCollectionExtensions"); + + cancellationToken.ThrowIfCancellationRequested(); + var source = template.Render(new + { + Handlers = handlers.Select(x => x.displayName), + Behaviors = behaviors + .Concat(handlers.SelectMany(h => h.behaviors ?? Enumerable.Empty())) + .Distinct(), + }); + + cancellationToken.ThrowIfCancellationRequested(); + context.AddSource("ServiceCollectionExtensions.g.cs", source); + } + + private static void RenderHandler( + SourceProductionContext context, + Handler? handler, + ImmutableArray behaviors, + ImmutableArray renderModes, + bool hasMsDi, + Template template + ) + { + var cancellationToken = context.CancellationToken; + cancellationToken.ThrowIfCancellationRequested(); + + if (handler == null) + return; + + if (renderModes.Length > 1) + return; + + var renderMode = handler.OverrideRenderMode + ?? (renderModes.Length == 0 ? RenderMode.Normal : renderModes[0]); + + // Only support normal render mode for now + if (renderMode is not RenderMode.Normal) + return; + + var pipelineBehaviors = BuildPipeline( + handler.RequestType, + handler.ResponseType, + handler.OverrideBehaviors?.AsEnumerable() + ?? behaviors.AsEnumerable() + ); + + cancellationToken.ThrowIfCancellationRequested(); + if (pipelineBehaviors.Any(b => b is null)) + return; + + var handlerSource = template.Render(new + { + ClassFullyQualifiedName = handler.DisplayName, + handler.ClassName, + handler.Namespace, + + handler.MethodName, + HandlerParameters = handler.Parameters, + + RequestType = handler.RequestType.Name, + ResponseType = handler.ResponseType.Name, + + Behaviors = pipelineBehaviors, + HasMsDi = hasMsDi, + }); + + cancellationToken.ThrowIfCancellationRequested(); + context.AddSource($"{handler.Namespace}.{handler.ClassName}.g.cs", handlerSource); + } + + private static List BuildPipeline( + GenericType requestType, + GenericType responseType, + IEnumerable enumerable) => + enumerable + .Where(b => b is null || ValidateType(b.RequestType, requestType)) + .Where(b => b is null || ValidateType(b.ResponseType, responseType)) + .ToList(); + + private static bool ValidateType(string? type, GenericType implementedTypes) => + type is null + || implementedTypes.Implements + .Contains(type); + + private static Template GetTemplate(string name) + { + using var stream = Assembly + .GetExecutingAssembly() + .GetManifestResourceStream( + $"Immediate.Handlers.Generators.Templates.{name}.sbntxt" + )!; + + using var reader = new StreamReader(stream); + return Template.Parse(reader.ReadToEnd()); + } +} diff --git a/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformBehaviors.cs b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformBehaviors.cs new file mode 100644 index 00000000..315459be --- /dev/null +++ b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformBehaviors.cs @@ -0,0 +1,125 @@ +using Immediate.Handlers.Shared; +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Generators.ImmediateHandlers; + +public partial class ImmediateHandlersGenerator +{ + private static EquatableReadOnlyList TransformBehaviors( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var semanticModel = context.SemanticModel; + var compilation = semanticModel.Compilation; + cancellationToken.ThrowIfCancellationRequested(); + + return ParseBehaviors(context.Attributes[0], compilation, cancellationToken); + } + + private static EquatableReadOnlyList ParseBehaviors( + AttributeData attribute, + Compilation compilation, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var behaviorType = typeof(Behavior<,>); + var behaviorTypeSymbol = compilation.GetTypeByMetadataName(behaviorType.FullName!); + if (behaviorTypeSymbol is null) + return []; + + if (attribute.ConstructorArguments.Length != 1) + return []; + + var ca = attribute.ConstructorArguments[0]; + var arrayTypeSymbol = compilation.CreateArrayTypeSymbol(compilation.GetTypeByMetadataName("System.Type")!, 1); + if (!SymbolEqualityComparer.Default.Equals(ca.Type, arrayTypeSymbol)) + return []; + + cancellationToken.ThrowIfCancellationRequested(); + return ca.Values + .Select(v => + { + cancellationToken.ThrowIfCancellationRequested(); + if (v.Value is not INamedTypeSymbol symbol) + return null; + + if (!symbol.IsUnboundGenericType) + return null; + + var originalDefinition = symbol.OriginalDefinition; + if (originalDefinition.TypeParameters.Length != 2) + return null; + + if (originalDefinition.IsAbstract) + return null; + + if (!originalDefinition.ImplementsBaseClass(behaviorTypeSymbol)) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + + // global::Dummy.LoggingBehavior<,> + // for: `services.AddScoped(typeof(..));` + var typeName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + cancellationToken.ThrowIfCancellationRequested(); + + // global::Dummy.LoggingBehavior + // for: private readonly global::Dummy.LoggingBehavior + var constructorType = symbol.OriginalDefinition.ToDisplayString(DisplayNameFormatters.NonGenericFqdnFormat); + + var constraint = GetConstraintInfo(symbol, cancellationToken); + if (constraint == null) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + return new Behavior + { + RegistrationType = typeName, + NonGenericTypeName = constructorType, + RequestType = constraint.RequestType, + ResponseType = constraint.ResponseType, + }; + }) + .ToEquatableReadOnlyList(); + } + + private static ConstraintInfo? GetConstraintInfo(INamedTypeSymbol symbol, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var originalDefinition = symbol.OriginalDefinition; + + var requestConstraints = originalDefinition + .TypeParameters[0] + .ConstraintTypes; + if (requestConstraints.Length > 1) + return null; + + var requestType = requestConstraints.Length == 1 + ? requestConstraints[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : null; + + cancellationToken.ThrowIfCancellationRequested(); + var responseConstraints = originalDefinition + .TypeParameters[1] + .ConstraintTypes; + if (responseConstraints.Length > 1) + return null; + + var responseType = responseConstraints.Length == 1 + ? responseConstraints[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : null; + + cancellationToken.ThrowIfCancellationRequested(); + return new() + { + RequestType = requestType, + ResponseType = responseType, + }; + } +} diff --git a/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformHandler.cs b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformHandler.cs new file mode 100644 index 00000000..66064d05 --- /dev/null +++ b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformHandler.cs @@ -0,0 +1,135 @@ +using Immediate.Handlers.Shared; +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Generators.ImmediateHandlers; + +public partial class ImmediateHandlersGenerator +{ + private static Handler? TransformHandler( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var symbol = (INamedTypeSymbol)context.TargetSymbol; + + var @namespace = symbol.ContainingNamespace.ToString().NullIf(""); + var name = symbol.Name; + var displayName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + cancellationToken.ThrowIfCancellationRequested(); + if (symbol.ContainingType is not null) + return null; + + if (symbol + .GetMembers() + .OfType() + .Where(m => + m.Name.Equals("Handle", StringComparison.Ordinal) + || m.Name.Equals("HandleAsync", StringComparison.Ordinal) + ) + .ToList() is not [var handleMethod]) + { + return null; + } + + // must have request type and cancellation token + if (handleMethod.Parameters.Length < 2) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + var requestType = BuildGenericType((INamedTypeSymbol)handleMethod.Parameters[0].Type); + + var compilation = context.SemanticModel.Compilation; + var taskSymbol = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1")!; + + cancellationToken.ThrowIfCancellationRequested(); + var responseTypeSymbol = handleMethod.GetTaskReturnType(taskSymbol); + if (responseTypeSymbol is null) + return null; + + var responseType = BuildGenericType((INamedTypeSymbol)responseTypeSymbol); + + cancellationToken.ThrowIfCancellationRequested(); + var parameters = handleMethod.Parameters + .Skip(1).Take(handleMethod.Parameters.Length - 2) + .Select(p => new Parameter + { + Type = p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + Name = p.Name, + }) + .ToEquatableReadOnlyList(); + + cancellationToken.ThrowIfCancellationRequested(); + var renderMode = GetOverrideRenderMode(symbol); + + cancellationToken.ThrowIfCancellationRequested(); + var behaviors = GetOverrideBehaviors(symbol, context.SemanticModel.Compilation, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + return new() + { + Namespace = @namespace, + ClassName = name, + DisplayName = displayName, + + MethodName = handleMethod.Name, + + RequestType = requestType, + ResponseType = responseType, + + Parameters = parameters, + + OverrideRenderMode = renderMode, + OverrideBehaviors = behaviors, + }; + } + + private static RenderMode? GetOverrideRenderMode(INamedTypeSymbol symbol) => + symbol.GetAttribute("Immediate.Handlers.Shared.RenderModeAttribute") + is { } rma + ? ParseRenderMode(rma) + : null; + + private static EquatableReadOnlyList? GetOverrideBehaviors( + INamedTypeSymbol symbol, + Compilation compilation, + CancellationToken cancellationToken) => + symbol.GetAttribute("Immediate.Handlers.Shared.BehaviorsAttribute") + is { } ba + ? ParseBehaviors(ba, compilation, cancellationToken) + : null; + + private static GenericType BuildGenericType(ITypeSymbol type) + { + var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var implements = new List(); + AddBaseTypes(type, implements); + + return new() + { + Name = name, + Implements = implements.Distinct().ToEquatableReadOnlyList(), + }; + } + + private static void AddBaseTypes(ITypeSymbol type, List implements) + { + if (type.OriginalDefinition.ToString() is + "object" + or "System.Collections.IEnumerable" + or "System.IEquatable" + ) + { + return; + } + + implements.Add(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + if (type.BaseType is not null) + AddBaseTypes(type.BaseType, implements); + + foreach (var i in type.Interfaces) + AddBaseTypes(i, implements); + } +} diff --git a/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformRenderMode.cs b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformRenderMode.cs new file mode 100644 index 00000000..cd6d84c6 --- /dev/null +++ b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_TransformRenderMode.cs @@ -0,0 +1,19 @@ +using Immediate.Handlers.Shared; +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Generators.ImmediateHandlers; + +public partial class ImmediateHandlersGenerator +{ + private static RenderMode TransformRenderMode(GeneratorAttributeSyntaxContext context, CancellationToken token) + => ParseRenderMode(context.Attributes[0]); + + private static RenderMode ParseRenderMode(AttributeData attr) + { + if (attr.ConstructorArguments.Length != 1) + return RenderMode.None; + + var ca = attr.ConstructorArguments[0]; + return (RenderMode?)(int?)ca.Value ?? RenderMode.None; + } +} diff --git a/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Types.cs b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Types.cs new file mode 100644 index 00000000..f94f3bd2 --- /dev/null +++ b/src/Immediate.Handlers.Generators/ImmediateHandlers/ImmediateHandlersGenerator_Types.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using Immediate.Handlers.Shared; + +// ReSharper disable UnusedAutoPropertyAccessor.Local + +namespace Immediate.Handlers.Generators.ImmediateHandlers; + +public partial class ImmediateHandlersGenerator +{ + [ExcludeFromCodeCoverage] + private sealed record Behavior + { + public required string RegistrationType { get; init; } + public required string NonGenericTypeName { get; init; } + public required string? RequestType { get; init; } + public required string? ResponseType { get; init; } + } + + [ExcludeFromCodeCoverage] + private sealed record Parameter + { + public required string Type { get; init; } + public required string Name { get; init; } + } + + [ExcludeFromCodeCoverage] + private sealed record GenericType + { + public required string Name { get; init; } + public required EquatableReadOnlyList Implements { get; init; } + } + + [ExcludeFromCodeCoverage] + private sealed record Handler + { + public required string? Namespace { get; init; } + public required string ClassName { get; init; } + public required string DisplayName { get; init; } + + public required string MethodName { get; init; } + public required EquatableReadOnlyList Parameters { get; init; } + + public required GenericType RequestType { get; init; } + public required GenericType ResponseType { get; init; } + + public EquatableReadOnlyList? OverrideBehaviors { get; init; } + public RenderMode? OverrideRenderMode { get; init; } + } + + [ExcludeFromCodeCoverage] + private sealed record ConstraintInfo + { + public required string? RequestType { get; init; } + public required string? ResponseType { get; init; } + } +} diff --git a/src/Immediate.Handlers.Generators/Properties/launchSettings.json b/src/Immediate.Handlers.Generators/Properties/launchSettings.json new file mode 100644 index 00000000..b19d2a16 --- /dev/null +++ b/src/Immediate.Handlers.Generators/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Normal": { + "commandName": "DebugRoslynComponent", + "targetProject": "..//..//Samples//Normal//Normal.csproj" + } + } +} diff --git a/src/Immediate.Handlers.Generators/Templates/Handler.sbntxt b/src/Immediate.Handlers.Generators/Templates/Handler.sbntxt new file mode 100644 index 00000000..bdd7ff5f --- /dev/null +++ b/src/Immediate.Handlers.Generators/Templates/Handler.sbntxt @@ -0,0 +1,97 @@ +{{~ if has_ms_di ~}} +using Microsoft.Extensions.DependencyInjection; + +{{~ end ~}} +#pragma warning disable CS1591 + +{{~ if !string.empty namespace ~}} +namespace {{ namespace }}; + +{{~ end ~}} +partial class {{ class_name }} +{ + public sealed class Handler + { + private readonly {{ class_fully_qualified_name }}.HandleBehavior _behavior_0; + {{~ for behavior in (behaviors | array.reverse) ~}} + private readonly {{ behavior.non_generic_type_name }}<{{ request_type }}, {{ response_type }}> _behavior_{{for.index + 1}}; + {{~ end ~}} + + public Handler( + {{ class_fully_qualified_name }}.HandleBehavior behavior_0{{ if behaviors.size > 0 }},{{ end }} + {{~ for behavior in (behaviors | array.reverse) ~}} + {{ behavior.non_generic_type_name }}<{{ request_type }}, {{ response_type }}> behavior_{{for.index + 1}}{{ if !for.last }},{{end }} + {{~ end ~}} + ) + { + _behavior_0 = behavior_0; + {{~ for behavior in behaviors ~}} + _behavior_{{for.index + 1}} = behavior_{{for.index + 1}}; + {{~ end ~}} + + {{~ $length = behaviors.size ~}} + {{~ if $length > 0 ~}} + {{~ for i in 1..$length ~}} + _behavior_{{ i }}.SetInnerHandler(_behavior_{{ i - 1 }}); + {{~ end ~}} + {{~ end ~}} + } + + public async global::System.Threading.Tasks.Task<{{ response_type }}> HandleAsync( + {{ request_type }} request, + global::System.Threading.CancellationToken cancellationToken = default + ) + { + return await _behavior_{{ $length }} + .HandleAsync(request, cancellationToken) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class HandleBehavior : global::Immediate.Handlers.Shared.Behavior<{{ request_type }}, {{ response_type }}> + { + {{~ for parameter in handler_parameters ~}} + private readonly {{ parameter.type }} _{{ parameter.name }}; + {{~ end ~}} + + public HandleBehavior( + {{~ for parameter in handler_parameters ~}} + {{ parameter.type }} {{ parameter.name }}{{ if !for.last }},{{ end }} + {{~ end ~}} + ) + { + {{~ for parameter in handler_parameters ~}} + _{{ parameter.name }} = {{ parameter.name }}; + {{~ end ~}} + } + + public override async global::System.Threading.Tasks.Task<{{ response_type }}> HandleAsync( + {{ request_type }} request, + global::System.Threading.CancellationToken cancellationToken + ) + { + return await {{ class_fully_qualified_name }} + .{{ method_name }}( + request, + {{~ for parameter in handler_parameters ~}} + _{{ parameter.name }}, + {{~ end ~}} + cancellationToken + ) + .ConfigureAwait(false); + } + } + {{~ if has_ms_di ~}} + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + services.AddScoped<{{ class_fully_qualified_name }}.Handler>(); + services.AddScoped<{{ class_fully_qualified_name }}.HandleBehavior>(); + return services; + } + {{~ end ~}} +} diff --git a/src/Immediate.Handlers.Generators/Templates/ServiceCollectionExtensions.sbntxt b/src/Immediate.Handlers.Generators/Templates/ServiceCollectionExtensions.sbntxt new file mode 100644 index 00000000..46e9d659 --- /dev/null +++ b/src/Immediate.Handlers.Generators/Templates/ServiceCollectionExtensions.sbntxt @@ -0,0 +1,26 @@ +#pragma warning disable CS1591 + +namespace Microsoft.Extensions.DependencyInjection; + +public static class HandlerServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddBehaviors( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + {{~ for b in behaviors ~}} + services.AddScoped(typeof({{ b.registration_type }})); + {{~ end ~}} + + return services; + } + + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + {{~ for h in handlers ~}} + {{ h }}.AddHandlers(services); + {{~ end ~}} + + return services; + } +} diff --git a/src/Immediate.Handlers.Generators/Utility.cs b/src/Immediate.Handlers.Generators/Utility.cs new file mode 100644 index 00000000..26362edf --- /dev/null +++ b/src/Immediate.Handlers.Generators/Utility.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Generators; + +internal static class Utility +{ + public static bool ImplementsBaseClass(this INamedTypeSymbol typeSymbol, INamedTypeSymbol typeToCheck) => + SymbolEqualityComparer.Default.Equals(typeSymbol, typeToCheck) + || (typeSymbol.BaseType is not null + && ImplementsBaseClass(typeSymbol.BaseType.OriginalDefinition, typeToCheck) + ); + + public static ITypeSymbol? GetTaskReturnType(this IMethodSymbol method, INamedTypeSymbol taskSymbol) => + !method.ReturnsVoid && SymbolEqualityComparer.Default.Equals(method.ReturnType.OriginalDefinition, taskSymbol) + ? ((INamedTypeSymbol)method.ReturnType).TypeArguments.FirstOrDefault() + : null; + + public static AttributeData? GetAttribute(this INamedTypeSymbol symbol, string attribute) => + symbol + .GetAttributes() + .FirstOrDefault(a => + a.AttributeClass?.ToString() == attribute + ); + + public static string? NullIf(this string value, string check) => + value.Equals(check, StringComparison.Ordinal) ? null : value; +} diff --git a/src/Immediate.Handlers.Shared/.editorconfig b/src/Immediate.Handlers.Shared/.editorconfig new file mode 100644 index 00000000..31f894bd --- /dev/null +++ b/src/Immediate.Handlers.Shared/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] + +# XML Documentation +dotnet_diagnostic.CS0105.severity = error # CS0105: Using directive is unnecessary. +dotnet_diagnostic.CS1573.severity = error # CS1573: Missing XML comment for parameter +dotnet_diagnostic.CS1591.severity = error # CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1712.severity = error # CS1712: Type parameter has no matching typeparam tag in the XML comment (but other type parameters do) diff --git a/src/Immediate.Handlers.Shared/Behavior.cs b/src/Immediate.Handlers.Shared/Behavior.cs new file mode 100644 index 00000000..e0c92cc8 --- /dev/null +++ b/src/Immediate.Handlers.Shared/Behavior.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Handlers.Shared; + +/// +/// Represents a cross-cutting pipeline behavior +/// +/// +/// The type of a command-request +/// +/// +/// The type of a command-response +/// +public abstract class Behavior +{ + private Behavior? _innerHandler; + + [DoesNotReturn] + private static void ThrowException(string message) => + throw new InvalidOperationException(message); + + /// + /// The next entry in the pipeline for the current request. + /// + /// + /// This property is called by the infrastructure, and should not be called manually. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public void SetInnerHandler(Behavior handler) + { + if (_innerHandler != null) + ThrowException("Cannot set `_innerHandler` more than once."); + _innerHandler = handler; + } + + /// + /// Calls the next entry in the pipeline for the current request. + /// + /// + /// The currently processing request. + /// + /// + /// The optional cancellation token to be used for cancelling the operation at any time. + /// + /// + /// A representing a promise to return a from the + /// next entry in the pipeline. + /// + protected Task Next(TRequest request, CancellationToken cancellationToken) + { + if (_innerHandler == null) + ThrowException("`_innerHandler` must be set before calling `Next()`"); + return _innerHandler.HandleAsync(request, cancellationToken); + } + + /// + /// Pipeline handler. Perform any additional behavior and + /// Next(request, cancellationToken). + /// + /// + /// Incoming request + /// + /// + /// The optional cancellation token to be used for cancelling the operation at any time. + /// + /// + /// A representing a promise to return a . + /// + public abstract Task HandleAsync(TRequest request, CancellationToken cancellationToken); +} diff --git a/src/Immediate.Handlers.Shared/BehaviorsAttribute.cs b/src/Immediate.Handlers.Shared/BehaviorsAttribute.cs new file mode 100644 index 00000000..6a3eca63 --- /dev/null +++ b/src/Immediate.Handlers.Shared/BehaviorsAttribute.cs @@ -0,0 +1,31 @@ +namespace Immediate.Handlers.Shared; + +/// +/// Allows the specification of s that should be used as part of the +/// pipeline for handling a request. +/// +/// +/// +/// If applied to the Assembly ([assembly: Behavior()]), then the given +/// s will be part of the pipeline for all requests across the assembly. +/// +/// +/// If applied to a , then the given s will +/// be part of the pipeline for the request. +/// +/// +/// However, any that is invalid for a given type will be excluded from +/// the pipeline for that type. +/// +/// +/// +/// The types for each of the s that should be part of the pipeline. +/// +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)] +public sealed class BehaviorsAttribute(params Type[] types) : Attribute +{ + /// + /// The types for each of the s that should be part of the pipeline. + /// + public Type[] Types { get; } = types; +} diff --git a/src/Immediate.Handlers.Shared/HandlerAttribute.cs b/src/Immediate.Handlers.Shared/HandlerAttribute.cs new file mode 100644 index 00000000..97d5d6e0 --- /dev/null +++ b/src/Immediate.Handlers.Shared/HandlerAttribute.cs @@ -0,0 +1,7 @@ +namespace Immediate.Handlers.Shared; + +/// +/// Applied to a class to indicate that handler code should be generated. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class HandlerAttribute : Attribute; diff --git a/src/Immediate.Handlers.Utility/Immediate.Handlers.Utility.csproj b/src/Immediate.Handlers.Shared/Immediate.Handlers.Shared.csproj similarity index 65% rename from src/Immediate.Handlers.Utility/Immediate.Handlers.Utility.csproj rename to src/Immediate.Handlers.Shared/Immediate.Handlers.Shared.csproj index 8a960fe0..b3fbfff7 100644 --- a/src/Immediate.Handlers.Utility/Immediate.Handlers.Utility.csproj +++ b/src/Immediate.Handlers.Shared/Immediate.Handlers.Shared.csproj @@ -1,8 +1,9 @@ - + netstandard2.0 false + $(NoWarn);CA1716 - + diff --git a/src/Immediate.Handlers.Shared/RenderModeAttribute.cs b/src/Immediate.Handlers.Shared/RenderModeAttribute.cs new file mode 100644 index 00000000..fc661de2 --- /dev/null +++ b/src/Immediate.Handlers.Shared/RenderModeAttribute.cs @@ -0,0 +1,41 @@ +namespace Immediate.Handlers.Shared; + +/// +/// Specifies which type of handler should be rendered +/// +public enum RenderMode +{ + /// + /// Represents an invalid entry, and should not be used. + /// + None, + + /// + /// A common handler should be rendered. + /// + Normal, +} + +/// +/// Allows the specification of which type of handler should be rendered. +/// +/// +/// +/// If applied to the Assembly ([assembly: RenderMode()]), then all handlers will use the given unless overriden. +/// +/// +/// If applied to a , then the specified handler will be rendered. +/// +/// +/// +/// Which type of handler should be rendered +/// +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)] +public sealed class RenderModeAttribute(RenderMode renderMode) : Attribute +{ + /// + /// Which type of handler should be rendered + /// + public RenderMode RenderMode { get; } = renderMode; +} diff --git a/src/Immediate.Handlers/Immediate.Handlers.csproj b/src/Immediate.Handlers/Immediate.Handlers.csproj index c6c6610d..73da3b51 100644 --- a/src/Immediate.Handlers/Immediate.Handlers.csproj +++ b/src/Immediate.Handlers/Immediate.Handlers.csproj @@ -1,10 +1,9 @@ - + netstandard2.0 + true false - true - true @@ -20,26 +19,30 @@ https://github.com/viceroypenguin/Immediate.Handlers - - - - - - - - - + + + + + + + + + + + - - + - + + + + diff --git a/src/Immediate.Handlers/Properties/launchSettings.json b/src/Immediate.Handlers/Properties/launchSettings.json deleted file mode 100644 index f3158850..00000000 --- a/src/Immediate.Handlers/Properties/launchSettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "profiles": { - "Immediate.Handlers.Samples": { - "commandName": "DebugRoslynComponent", - "targetProject": "..//..//Samples//Immediate.Handlers.Samples//Immediate.Handlers.Samples.csproj" - } - } -} diff --git a/tests/Immediate.Handlers.FunctionalTests/Behavior/BehaviorTests.cs b/tests/Immediate.Handlers.FunctionalTests/Behavior/BehaviorTests.cs new file mode 100644 index 00000000..18f81e26 --- /dev/null +++ b/tests/Immediate.Handlers.FunctionalTests/Behavior/BehaviorTests.cs @@ -0,0 +1,31 @@ +using Xunit; + +namespace Immediate.Handlers.FunctionalTests.Behavior; + +public sealed class BehaviorTests +{ + private sealed class TestBehavior : Shared.Behavior + { + public override async Task HandleAsync(int request, CancellationToken cancellationToken) + { + return await Next(request, cancellationToken); + } + } + + [Fact] + public void CannotSetHandlerTwice() + { + var handler = new TestBehavior(); + handler.SetInnerHandler(handler); + _ = Assert.Throws(() => + handler.SetInnerHandler(handler)); + } + + [Fact] + public async Task MustSetHandlerBeforeCallingNext() + { + var handler = new TestBehavior(); + _ = await Assert.ThrowsAsync(() => + handler.HandleAsync(1, CancellationToken.None)); + } +} diff --git a/tests/Immediate.Handlers.FunctionalTests/Immediate.Handlers.FunctionalTests.csproj b/tests/Immediate.Handlers.FunctionalTests/Immediate.Handlers.FunctionalTests.csproj new file mode 100644 index 00000000..8f1a3a4c --- /dev/null +++ b/tests/Immediate.Handlers.FunctionalTests/Immediate.Handlers.FunctionalTests.csproj @@ -0,0 +1,20 @@ + + + + Immediate.Handlers.FunctionalTests + + + + + + + + + + + + + + + + diff --git a/tests/Immediate.Handlers.FunctionalTests/MultipleBehaviors/MultipleBehaviorsTests.cs b/tests/Immediate.Handlers.FunctionalTests/MultipleBehaviors/MultipleBehaviorsTests.cs new file mode 100644 index 00000000..20ae6b34 --- /dev/null +++ b/tests/Immediate.Handlers.FunctionalTests/MultipleBehaviors/MultipleBehaviorsTests.cs @@ -0,0 +1,75 @@ +using Immediate.Handlers.Shared; +using Xunit; + +namespace Immediate.Handlers.FunctionalTests.MultipleBehaviors; + +public class Behavior1 : Behavior + where TRequest : List +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Add("Behavior1.Enter"); + var response = await Next(request, cancellationToken); + request.Add("Behavior1.Exit"); + + return response; + } +} + +public class Behavior2 : Behavior + where TRequest : List +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Add("Behavior2.Enter"); + var response = await Next(request, cancellationToken); + request.Add("Behavior2.Exit"); + + return response; + } +} + +[Handler] +[Behaviors( + typeof(Behavior1<,>), + typeof(Behavior2<,>) +)] +public static partial class MultipleBehaviorHandler +{ + public class Query : List; + + private static async Task HandleAsync(Query query, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + query.Add("Query.HandleAsync"); + await Task.Yield(); + return 3; + } +} + +public sealed class MultipleBehaviorsTests +{ + [Fact] + public async Task TestBehaviorOrdering() + { + var query = new MultipleBehaviorHandler.Query(); + var handler = new MultipleBehaviorHandler.Handler( + new(), new(), new()); + + _ = await handler.HandleAsync(query); + + Assert.Equal( + [ + "Behavior1.Enter", + "Behavior2.Enter", + "Query.HandleAsync", + "Behavior2.Exit", + "Behavior1.Exit", + ], + query); + } +} diff --git a/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterizedTests.cs b/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterizedTests.cs new file mode 100644 index 00000000..a6557b52 --- /dev/null +++ b/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterizedTests.cs @@ -0,0 +1,48 @@ +using Immediate.Handlers.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Immediate.Handlers.FunctionalTests.NoBehaviors; + +[Handler] +public static partial class NoBehaviorParameterizedOneAdder +{ + public sealed record Query(int Input); + + private static Task Handle( + Query query, + AddendProvider addendProvider, + CancellationToken _) + { + return Task.FromResult(query.Input + addendProvider.Addend); + } +} + +public class AddendProvider +{ +#pragma warning disable CA1822 + public int Addend => 1; +#pragma warning restore CA1822 +} + +public class ParameterizedTests +{ + [Fact] + public async Task NoBehaviorShouldReturnExpectedResponse() + { + const int Input = 1; + + var serviceProvider = new ServiceCollection() + .AddHandlers() + .AddScoped() + .BuildServiceProvider(); + + var handler = serviceProvider.GetRequiredService(); + + var query = new NoBehaviorParameterizedOneAdder.Query(Input); + + var result = await handler.HandleAsync(query); + + Assert.Equal(Input + serviceProvider.GetRequiredService().Addend, result); + } +} diff --git a/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterlessTests.cs b/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterlessTests.cs new file mode 100644 index 00000000..f2cee240 --- /dev/null +++ b/tests/Immediate.Handlers.FunctionalTests/NoBehaviors/ParameterlessTests.cs @@ -0,0 +1,39 @@ +using Immediate.Handlers.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Immediate.Handlers.FunctionalTests.NoBehaviors; + +[Handler] +public static partial class NoBehaviorParameterlessOneAdder +{ + public sealed record Query(int Input); + + private static Task HandleAsync( + Query query, + CancellationToken _) + { + return Task.FromResult(query.Input + 1); + } +} + +public class ParameterlessTests +{ + [Fact] + public async Task NoBehaviorShouldReturnExpectedResponse() + { + const int Input = 1; + + var serviceProvider = new ServiceCollection() + .AddHandlers() + .BuildServiceProvider(); + + var handler = serviceProvider.GetRequiredService(); + + var query = new NoBehaviorParameterlessOneAdder.Query(Input); + + var result = await handler.HandleAsync(query); + + Assert.Equal(Input + 1, result); + } +} diff --git a/tests/Immediate.Handlers.Tests/.editorconfig b/tests/Immediate.Handlers.Tests/.editorconfig new file mode 100644 index 00000000..8b1d7f1d --- /dev/null +++ b/tests/Immediate.Handlers.Tests/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] + +dotnet_diagnostic.CA1707.severity = none # CA1707: Identifiers should not contain underscores diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/AnalyzerTestHelpers.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/AnalyzerTestHelpers.cs new file mode 100644 index 00000000..e7a37126 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/AnalyzerTestHelpers.cs @@ -0,0 +1,33 @@ +using Immediate.Handlers.Tests.Helpers; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Immediate.Handlers.Tests.AnalyzerTests; + +public static class AnalyzerTestHelpers +{ + public static CSharpAnalyzerTest CreateAnalyzerTest( + string inputSource, + DriverReferenceAssemblies assemblies, + IEnumerable expectedDiagnostics + ) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var csTest = new CSharpAnalyzerTest + { + TestState = + { + Sources = { inputSource }, + }, + }; + + csTest.TestState.AdditionalReferences + .AddRange(assemblies.GetAdditionalReferences()); + + csTest.TestState.ExpectedDiagnostics + .AddRange(expectedDiagnostics); + + return csTest; + } +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotHaveTwoGenericParameters.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotHaveTwoGenericParameters.cs new file mode 100644 index 00000000..c5500fda --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotHaveTwoGenericParameters.cs @@ -0,0 +1,78 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +// using Verifier = +// Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier< +// Immediate.Handlers.Analyzers.BehaviorsAnalyzer>; + +namespace Immediate.Handlers.Tests.AnalyzerTests.BehaviorAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task BehaviorTypeDoesNotHaveTwoGenericParameters_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Normal; + + [assembly: Behaviors( + typeof({|IHR0007:LoggingBehavior<,,>|}) + )] + + namespace Normal; + + public class User { }; + public interface ILogger; + + public class LoggingBehavior(ILogger> logger) + : Immediate.Handlers.Shared.Behavior + { + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + _ = logger.ToString(); + var response = await Next(request, cancellationToken); + + return response; + } + } + + public class UsersService(ILogger logger) + { + public Task> GetUsers() + { + _ = logger.ToString(); + return Task.FromResult(Enumerable.Empty()); + } + } + + [Handler] + [Behaviors( + typeof({|IHR0007:LoggingBehavior<,,>|}) + )] + public static partial class GetUsersQuery + { + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + return usersService.GetUsers(); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotInheritFromGenericBehavior.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotInheritFromGenericBehavior.cs new file mode 100644 index 00000000..9001489e --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotInheritFromGenericBehavior.cs @@ -0,0 +1,77 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.BehaviorAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task BehaviorTypeDoesNotInheritFromGenericBehavior_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Normal; + + [assembly: Behaviors( + typeof({|IHR0006:LoggingBehavior<,>|}), + typeof(TestBehavior<,>) + )] + + namespace Normal; + + public class User { } + public interface ILogger; + + public class LoggingBehavior(ILogger> logger) + { + } + + public class TestBehavior : Immediate.Handlers.Shared.Behavior + where TRequest : User + { + public override Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + public class UsersService(ILogger logger) + { + public Task> GetUsers() + { + _ = logger.ToString(); + return Task.FromResult(Enumerable.Empty()); + } + } + + [Handler] + [Behaviors( + typeof({|IHR0006:LoggingBehavior<,>|}), + typeof(TestBehavior<,>) + )] + public static partial class GetUsersQuery + { + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + return usersService.GetUsers(); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotUseUnboundedReference.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotUseUnboundedReference.cs new file mode 100644 index 00000000..25916506 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeDoesNotUseUnboundedReference.cs @@ -0,0 +1,74 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.BehaviorAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task BehaviorTypeDoesNotUseUnboundedReference_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Normal; + + [assembly: Behaviors( + typeof({|IHR0008:LoggingBehavior|}) + )] + + namespace Normal; + + public class User { }; + public interface ILogger; + + public class LoggingBehavior(ILogger> logger) + : Immediate.Handlers.Shared.Behavior + { + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + _ = logger.ToString(); + var response = await Next(request, cancellationToken); + + return response; + } + } + + public class UsersService(ILogger logger) + { + public Task> GetUsers() + { + _ = logger.ToString(); + return Task.FromResult(Enumerable.Empty()); + } + } + + [Handler] + [Behaviors( + typeof({|IHR0008:LoggingBehavior|}) + )] + public static partial class GetUsersQuery + { + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + return usersService.GetUsers(); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeIsValid.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeIsValid.cs new file mode 100644 index 00000000..f61b3229 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/BehaviorAnalyzerTests/Tests.BehaviorTypeIsValid.cs @@ -0,0 +1,85 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.BehaviorAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task BehaviorTypeIsValid_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + using Normal; + + [assembly: Behaviors( + typeof(LoggingBehavior<,>), + typeof(TestBehavior<,>) + )] + + namespace Normal; + + public class User { } + public interface ILogger; + + public class LoggingBehavior(ILogger> logger) + : Immediate.Handlers.Shared.Behavior + { + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + _ = logger.ToString(); + var response = await Next(request, cancellationToken); + + return response; + } + } + + public class TestBehavior : Immediate.Handlers.Shared.Behavior + where TRequest : User + { + public override Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + public class UsersService(ILogger logger) + { + public Task> GetUsers() + { + _ = logger.ToString(); + return Task.FromResult(Enumerable.Empty()); + } + } + + [Handler] + [Behaviors( + typeof(LoggingBehavior<,>), + typeof(TestBehavior<,>) + )] + public static partial class GetUsersQuery + { + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + return usersService.GetUsers(); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs new file mode 100644 index 00000000..be4942e0 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs @@ -0,0 +1,30 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleClassDoesNotDefineCommandOrQuery_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0009:{|IHR0001:GetUsersQuery|}|} + { + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotExist.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotExist.cs new file mode 100644 index 00000000..57f313dd --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotExist.cs @@ -0,0 +1,31 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodDoesNotExist_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0001:GetUsersQuery|} + { + public record Query; + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotReturnTask.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotReturnTask.cs new file mode 100644 index 00000000..63efce9b --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodDoesNotReturnTask.cs @@ -0,0 +1,51 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodDoesNotReturnTask_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static IEnumerable? {|IHR0002:HandleAsync|}( + Query _, + UsersService usersService, + CancellationToken token) + { + return null; + } + } + + public class User { } + public class UsersService(ILogger logger) + { + public Task> GetUsers() + { + _ = logger.ToString(); + return Task.FromResult(Enumerable.Empty()); + } + } + + public interface ILogger; + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodFirstParamIsNotQuery.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodFirstParamIsNotQuery.cs new file mode 100644 index 00000000..dcaa6556 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodFirstParamIsNotQuery.cs @@ -0,0 +1,38 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodFirstParamIsNotQuery_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task {|IHR0003:HandleAsync|}( + int _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodIsCorrect.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodIsCorrect.cs new file mode 100644 index 00000000..60c49f60 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodIsCorrect.cs @@ -0,0 +1,38 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodIsCorrect_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodNoCancellationToken.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodNoCancellationToken.cs new file mode 100644 index 00000000..6f5d16c2 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodNoCancellationToken.cs @@ -0,0 +1,38 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodMissingCancellationToken_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task {|IHR0003:HandleAsync|}( + Query _, + int parameter2) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodOneParameter.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodOneParameter.cs new file mode 100644 index 00000000..4cae19a4 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandleMethodOneParameter.cs @@ -0,0 +1,37 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodDoesNotDefineTwoParameters_AlertDiagnostic() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record SomeQuery; + + private static Task {|IHR0003:HandleAsync|}( + SomeQuery _) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNested.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNested.cs new file mode 100644 index 00000000..ea5137a9 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNested.cs @@ -0,0 +1,39 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +public partial class Tests +{ + [Fact] + public async Task HandlerClassNested_DoesAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + public class Wrapper { + [Handler] + public static class {|IHR0005:GetUsersQuery|} + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNotNested.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNotNested.cs new file mode 100644 index 00000000..897fb083 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/HandlerClassAnalyzerTests/Tests.HandlerClassNotNested.cs @@ -0,0 +1,38 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.HandlerClassAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandlerClassNotNested_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeCast.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeCast.cs new file mode 100644 index 00000000..406df600 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeCast.cs @@ -0,0 +1,41 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.RenderModeAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task RenderModeCast_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [assembly: {|IHR0004:RenderMode((RenderMode)1)|}] + + [Handler] + [{|IHR0004:RenderMode((RenderMode)1)|}] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNone.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNone.cs new file mode 100644 index 00000000..0d6a3db9 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNone.cs @@ -0,0 +1,41 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.RenderModeAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task RenderModeIsNone_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [assembly: {|IHR0004:RenderMode(RenderMode.None)|}] + + [Handler] + [{|IHR0004:RenderMode(RenderMode.None)|}] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNormal.cs b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNormal.cs new file mode 100644 index 00000000..2032f822 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/AnalyzerTests/RenderModeAnalyzerTests/Tests.RenderModeIsNormal.cs @@ -0,0 +1,41 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.AnalyzerTests.RenderModeAnalyzerTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task RenderModeIsNormal_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [assembly: RenderMode(RenderMode.Normal)] + + [Handler] + [RenderMode(RenderMode.Normal)] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync( + Query _, + CancellationToken token) + { + return Task.FromResult(0); + } + } + """, + DriverReferenceAssemblies.Normal, + [] + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/CodeFixTests/CodeFixTestHelper.cs b/tests/Immediate.Handlers.Tests/CodeFixTests/CodeFixTestHelper.cs new file mode 100644 index 00000000..f47724ad --- /dev/null +++ b/tests/Immediate.Handlers.Tests/CodeFixTests/CodeFixTestHelper.cs @@ -0,0 +1,55 @@ +using Immediate.Handlers.Tests.Helpers; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Immediate.Handlers.Tests.CodeFixTests; + +public static class CodeFixTestHelper +{ + private const string EditorConfig = """ + root = true + + [*.cs] + charset = utf-8 + end_of_line = lf + indent_style = tab + insert_final_newline = true + indent_size = 4 + """; + + public static CSharpCodeFixTest CreateCodeFixTest( + string inputSource, + string fixedSource, + DriverReferenceAssemblies assemblies, + int codeActionIndex = 0 + ) + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + { + var csTest = new CSharpCodeFixTest + { + CodeActionIndex = codeActionIndex, + TestState = + { + Sources = { inputSource }, + AnalyzerConfigFiles = + { + { ("/.editorconfig", EditorConfig) }, + }, + }, + FixedState = + { + MarkupHandling = MarkupMode.IgnoreFixable, + Sources = { fixedSource }, + }, + }; + + csTest.TestState.AdditionalReferences + .AddRange(assemblies.GetAdditionalReferences()); + + return csTest; + } + +} diff --git a/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs b/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs new file mode 100644 index 00000000..b61a1145 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleClassDoesNotDefineCommandOrQuery.cs @@ -0,0 +1,84 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.CodeFixes; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.CodeFixTests.HandlerClassCodeFixTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleClassDoesNotDefineCommandOrQuery_AddsQuery() => + await CodeFixTestHelper.CreateCodeFixTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0009:{|IHR0001:GetUsersQuery|}|} + { + } + """, + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0001:GetUsersQuery|} + { + public record Query; + } + """, + DriverReferenceAssemblies.Normal + ).RunAsync(); + + [Fact] + public async Task HandleClassDoesNotDefineCommandOrQuery_AddsCommand() => + await CodeFixTestHelper.CreateCodeFixTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0009:{|IHR0001:GetUsersQuery|}|} + { + } + """, + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0001:GetUsersQuery|} + { + public record Command; + } + """, + DriverReferenceAssemblies.Normal, + 1 + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleMethodDoesNotExist.cs b/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleMethodDoesNotExist.cs new file mode 100644 index 00000000..5162784b --- /dev/null +++ b/tests/Immediate.Handlers.Tests/CodeFixTests/HandlerClassCodeFixTests/Tests.HandleMethodDoesNotExist.cs @@ -0,0 +1,52 @@ +using Immediate.Handlers.Analyzers; +using Immediate.Handlers.CodeFixes; +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.CodeFixTests.HandlerClassCodeFixTests; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")] +public partial class Tests +{ + [Fact] + public async Task HandleMethodDoesNotExist() => + await CodeFixTestHelper.CreateCodeFixTest( + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class {|IHR0001:GetUsersQuery|} + { + public record Query; + } + """, + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Immediate.Handlers.Shared; + + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task HandleAsync(Query _, CancellationToken token) + { + return null; + } + } + """, + DriverReferenceAssemblies.Normal + ).RunAsync(); +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs new file mode 100644 index 00000000..66696e78 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: Dummy.GetUsersQuery.g.cs +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQuery +{ + public sealed class Handler + { + private readonly global::Dummy.GetUsersQuery.HandleBehavior _behavior_0; + private readonly global::Dummy.SecondLoggingBehavior> _behavior_1; + private readonly global::Dummy.LoggingBehavior> _behavior_2; + + public Handler( + global::Dummy.GetUsersQuery.HandleBehavior behavior_0, + global::Dummy.SecondLoggingBehavior> behavior_1, + global::Dummy.LoggingBehavior> behavior_2 + ) + { + _behavior_0 = behavior_0; + _behavior_1 = behavior_1; + _behavior_2 = behavior_2; + + _behavior_1.SetInnerHandler(_behavior_0); + _behavior_2.SetInnerHandler(_behavior_1); + } + + public async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken = default + ) + { + return await _behavior_2 + .HandleAsync(request, cancellationToken) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class HandleBehavior : global::Immediate.Handlers.Shared.Behavior> + { + private readonly global::Dummy.UsersService _usersService; + + public HandleBehavior( + global::Dummy.UsersService usersService + ) + { + _usersService = usersService; + } + + public override async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken + ) + { + return await global::Dummy.GetUsersQuery + .HandleAsync( + request, + _usersService, + cancellationToken + ) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 00000000..b055bb9b --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,24 @@ +//HintName: ServiceCollectionExtensions.g.cs +#pragma warning disable CS1591 + +namespace Microsoft.Extensions.DependencyInjection; + +public static class HandlerServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddBehaviors( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddScoped(typeof(global::Dummy.LoggingBehavior<,>)); + services.AddScoped(typeof(global::Dummy.SecondLoggingBehavior<,>)); + + return services; + } + + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + global::Dummy.GetUsersQuery.AddHandlers(services); + + return services; + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs new file mode 100644 index 00000000..f665d15c --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.MultipleBehaviors_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs @@ -0,0 +1,65 @@ +//HintName: Dummy.GetUsersQuery.g.cs +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQuery +{ + public sealed class Handler + { + private readonly global::Dummy.GetUsersQuery.HandleBehavior _behavior_0; + private readonly global::Dummy.SecondLoggingBehavior> _behavior_1; + private readonly global::Dummy.LoggingBehavior> _behavior_2; + + public Handler( + global::Dummy.GetUsersQuery.HandleBehavior behavior_0, + global::Dummy.SecondLoggingBehavior> behavior_1, + global::Dummy.LoggingBehavior> behavior_2 + ) + { + _behavior_0 = behavior_0; + _behavior_1 = behavior_1; + _behavior_2 = behavior_2; + + _behavior_1.SetInnerHandler(_behavior_0); + _behavior_2.SetInnerHandler(_behavior_1); + } + + public async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken = default + ) + { + return await _behavior_2 + .HandleAsync(request, cancellationToken) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class HandleBehavior : global::Immediate.Handlers.Shared.Behavior> + { + private readonly global::Dummy.UsersService _usersService; + + public HandleBehavior( + global::Dummy.UsersService usersService + ) + { + _usersService = usersService; + } + + public override async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken + ) + { + return await global::Dummy.GetUsersQuery + .HandleAsync( + request, + _usersService, + cancellationToken + ) + .ConfigureAwait(false); + } + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.cs new file mode 100644 index 00000000..cb19cdb6 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/MultipleBehaviorTest.cs @@ -0,0 +1,85 @@ +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.GeneratorTests.Behaviors; + +[UsesVerify] +public class MultipleBehaviorTest +{ + private const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +[assembly: RenderMode(renderMode: RenderMode.Normal)] + +[assembly: Behaviors( + typeof(LoggingBehavior<,>), + typeof(SecondLoggingBehavior<,>) +)] + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class SecondLoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task MultipleBehaviors(DriverReferenceAssemblies assemblies) + { + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs new file mode 100644 index 00000000..15524fa4 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#Dummy.GetUsersQuery.g.verified.cs @@ -0,0 +1,73 @@ +//HintName: Dummy.GetUsersQuery.g.cs +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQuery +{ + public sealed class Handler + { + private readonly global::Dummy.GetUsersQuery.HandleBehavior _behavior_0; + private readonly global::Dummy.LoggingBehavior> _behavior_1; + + public Handler( + global::Dummy.GetUsersQuery.HandleBehavior behavior_0, + global::Dummy.LoggingBehavior> behavior_1 + ) + { + _behavior_0 = behavior_0; + _behavior_1 = behavior_1; + + _behavior_1.SetInnerHandler(_behavior_0); + } + + public async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken = default + ) + { + return await _behavior_1 + .HandleAsync(request, cancellationToken) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class HandleBehavior : global::Immediate.Handlers.Shared.Behavior> + { + private readonly global::Dummy.UsersService _usersService; + + public HandleBehavior( + global::Dummy.UsersService usersService + ) + { + _usersService = usersService; + } + + public override async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken + ) + { + return await global::Dummy.GetUsersQuery + .HandleAsync( + request, + _usersService, + cancellationToken + ) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 00000000..b62eff2a --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Msdi#ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,23 @@ +//HintName: ServiceCollectionExtensions.g.cs +#pragma warning disable CS1591 + +namespace Microsoft.Extensions.DependencyInjection; + +public static class HandlerServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddBehaviors( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddScoped(typeof(global::Dummy.LoggingBehavior<,>)); + + return services; + } + + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddHandlers( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + global::Dummy.GetUsersQuery.AddHandlers(services); + + return services; + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs new file mode 100644 index 00000000..9e5ff962 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.SingleBehavior_assemblies=Normal#Dummy.GetUsersQuery.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: Dummy.GetUsersQuery.g.cs +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQuery +{ + public sealed class Handler + { + private readonly global::Dummy.GetUsersQuery.HandleBehavior _behavior_0; + private readonly global::Dummy.LoggingBehavior> _behavior_1; + + public Handler( + global::Dummy.GetUsersQuery.HandleBehavior behavior_0, + global::Dummy.LoggingBehavior> behavior_1 + ) + { + _behavior_0 = behavior_0; + _behavior_1 = behavior_1; + + _behavior_1.SetInnerHandler(_behavior_0); + } + + public async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken = default + ) + { + return await _behavior_1 + .HandleAsync(request, cancellationToken) + .ConfigureAwait(false); + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class HandleBehavior : global::Immediate.Handlers.Shared.Behavior> + { + private readonly global::Dummy.UsersService _usersService; + + public HandleBehavior( + global::Dummy.UsersService usersService + ) + { + _usersService = usersService; + } + + public override async global::System.Threading.Tasks.Task> HandleAsync( + global::Dummy.GetUsersQuery.Query request, + global::System.Threading.CancellationToken cancellationToken + ) + { + return await global::Dummy.GetUsersQuery + .HandleAsync( + request, + _usersService, + cancellationToken + ) + .ConfigureAwait(false); + } + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.cs new file mode 100644 index 00000000..9d06c40b --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/Behaviors/SingleBehaviorTest.cs @@ -0,0 +1,73 @@ +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.GeneratorTests.Behaviors; + +[UsesVerify] +public class SingleBehaviorTest +{ + private const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +[assembly: RenderMode(renderMode: RenderMode.Normal)] + +[assembly: Behaviors( + typeof(LoggingBehavior<,>) +)] + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task SingleBehavior(DriverReferenceAssemblies assemblies) + { + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/GeneratorTestHelper.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/GeneratorTestHelper.cs new file mode 100644 index 00000000..e45b8419 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/GeneratorTestHelper.cs @@ -0,0 +1,35 @@ +using Immediate.Handlers.Generators.ImmediateHandlers; +using Immediate.Handlers.Tests.Helpers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Immediate.Handlers.Tests.GeneratorTests; + +public static class GeneratorTestHelper +{ + public static GeneratorDriver GetDriver(string source, DriverReferenceAssemblies assemblies) + { + // Parse the provided string into a C# syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + // Create a Roslyn compilation for the syntax tree. + var compilation = CSharpCompilation.Create( + assemblyName: "Tests", + syntaxTrees: new[] { syntaxTree }, + references: + [ + .. Basic.Reference.Assemblies.NetStandard20.References.All, + .. assemblies.GetAdditionalReferences(), + ] + ); + + // Create an instance of our incremental source generator + var generator = new ImmediateHandlersGenerator(); + + // The GeneratorDriver is used to run our generator against a compilation + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + + // Run the source generator! + return driver.RunGenerators(compilation); + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidBehaviorsTest.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidBehaviorsTest.cs new file mode 100644 index 00000000..692643eb --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidBehaviorsTest.cs @@ -0,0 +1,439 @@ +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.GeneratorTests.InvalidCode; + +[UsesVerify] +public class InvalidBehaviorsTest +{ + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task NonBehaviorShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior<,>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task BoundGenericShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task NonGenericBehaviorShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior> +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task AbstractBehaviorShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior<,>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public abstract class LoggingBehavior(ILogger> logger) + : Behavior; + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task BehaviorHasTooManyTRequestConstraintsShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior<,>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior where TRequest : IEnumerable, IEquatable +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task BehaviorHasTooManyTResponseConstraintsShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior<,>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior where TResponse : IEnumerable, IEquatable +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task BehaviorHasTooManyTypeParametersShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[Behaviors( + typeof(LoggingBehavior<,,>) +)] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class LoggingBehavior(ILogger> logger) + : Behavior +{ + public override async Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + var response = await Next(request, cancellationToken); + + return response; + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidHandlerTest.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidHandlerTest.cs new file mode 100644 index 00000000..ebefd926 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidHandlerTest.cs @@ -0,0 +1,301 @@ +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.GeneratorTests.InvalidCode; + +[UsesVerify] +public class InvalidHandlerTest +{ + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task HandlerWithoutHandlerMethodShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task HandlerWithTwoHandlersMethodShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task> Handle( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task HandlerWithOneParameterShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task> HandleAsync( + Query _) + { + return usersService.GetUsers(); + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task HandlerWithVoidResponseShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static void HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task HandlerWithTaskResponseShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static Task HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + [InlineData(DriverReferenceAssemblies.Msdi)] + public async Task NestedHandlerShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +public class Wrapper +{ + [Handler] + public static class GetUsersQuery + { + public record Query; + + private static Task> HandleAsync( + Query _, + UsersService usersService, + CancellationToken token) + { + return usersService.GetUsers(); + } + } +} + +public class User { } +public class UsersService +{ + public Task> GetUsers() => + Task.FromResult(Enumerable.Empty()); +} + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } +} diff --git a/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidRenderModeTest.cs b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidRenderModeTest.cs new file mode 100644 index 00000000..3d11ac5f --- /dev/null +++ b/tests/Immediate.Handlers.Tests/GeneratorTests/InvalidCode/InvalidRenderModeTest.cs @@ -0,0 +1,93 @@ +using Immediate.Handlers.Tests.Helpers; + +namespace Immediate.Handlers.Tests.GeneratorTests.InvalidCode; + +[UsesVerify] +public class InvalidRenderModeTest +{ + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + public async Task InvalidRenderModeOnAssemblyShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Dummy; +using Immediate.Handlers.Shared; + +[assembly: RenderMode(renderMode: RenderMode.None)] + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +public static class GetUsersQuery +{ + public record Query; + + private static async Task> HandleAsync( + Query _, + CancellationToken token) + { + return []; + } +} + +public class User { } + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } + + [Theory] + [InlineData(DriverReferenceAssemblies.Normal)] + public async Task InvalidRenderModeOnHandlerShouldProduceNothing(DriverReferenceAssemblies assemblies) + { + const string Input = """ +using System.Threading.Tasks; +using Immediate.Handlers.Shared; + +namespace Dummy; + +public class GetUsersEndpoint(GetUsersQuery.Handler handler) +{ + public async Task> GetUsers() => + handler.HandleAsync(new GetUsersQuery.Query()); +} + +[Handler] +[RenderMode(renderMode: RenderMode.None)] +public static class GetUsersQuery +{ + public record Query; + + private static async Task> HandleAsync( + Query _, + CancellationToken token) + { + return []; + } +} + +public class User { } + +public interface ILogger; +"""; + + var driver = GeneratorTestHelper.GetDriver(Input, assemblies); + + var runResult = driver.GetRunResult(); + _ = await Verify(runResult) + .UseParameters(string.Join("_", assemblies)); + } +} diff --git a/tests/Immediate.Handlers.Tests/Helpers/ReferenceAssemblyHelpers.cs b/tests/Immediate.Handlers.Tests/Helpers/ReferenceAssemblyHelpers.cs new file mode 100644 index 00000000..7ce11733 --- /dev/null +++ b/tests/Immediate.Handlers.Tests/Helpers/ReferenceAssemblyHelpers.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Handlers.Tests.Helpers; + +public static class ReferenceAssemblyHelpers +{ + public static IEnumerable GetAdditionalReferences(this DriverReferenceAssemblies assemblies) + { + ArgumentNullException.ThrowIfNull(assemblies); + + List references = + [ + MetadataReference.CreateFromFile("./Immediate.Handlers.Shared.dll"), + ]; + + if (assemblies is DriverReferenceAssemblies.Normal) + return references; + + references.AddRange( + [ + MetadataReference.CreateFromFile("./Microsoft.Extensions.DependencyInjection.dll"), + MetadataReference.CreateFromFile("./Microsoft.Extensions.DependencyInjection.Abstractions.dll"), + ] + ); + + if (assemblies is DriverReferenceAssemblies.Msdi) + return references; + + // to be done with other renderers + throw new NotImplementedException(); + } +} + +public enum DriverReferenceAssemblies +{ + Normal, + Msdi, +} diff --git a/tests/Immediate.Handlers.Tests/Immediate.Handlers.Tests.csproj b/tests/Immediate.Handlers.Tests/Immediate.Handlers.Tests.csproj index 338075ec..4cd4dcf2 100644 --- a/tests/Immediate.Handlers.Tests/Immediate.Handlers.Tests.csproj +++ b/tests/Immediate.Handlers.Tests/Immediate.Handlers.Tests.csproj @@ -1,20 +1,31 @@ - + - - net6.0;net8.0 - false - - + + + + + + + + + - + + + + + + + + diff --git a/tests/Immediate.Handlers.Tests/ModuleInitializer.cs b/tests/Immediate.Handlers.Tests/ModuleInitializer.cs new file mode 100644 index 00000000..2d587d9e --- /dev/null +++ b/tests/Immediate.Handlers.Tests/ModuleInitializer.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +namespace Immediate.Handlers.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + VerifierSettings.AutoVerify(includeBuildServer: false); + VerifySourceGenerators.Initialize(); + } +}