From 133be7ab3b1c12cc00597970dc50ba47dbe80b60 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Wed, 3 Jun 2026 13:28:21 -0500 Subject: [PATCH] Add Immediate.Cache Generator --- .editorconfig | 48 +++-- Directory.Build.props | 8 + Directory.Packages.props | 4 + Immediate.Cache.slnx | 1 + readme.md | 34 +-- src/Common/ITypeSymbolExtensions.cs | 114 ++++++++++ src/Common/SyntaxExtensions.cs | 13 ++ src/Common/Utility.cs | 12 ++ .../Immediate.Cache.Analyzers.csproj | 16 +- .../OwnedDisposableScopeSuppressor.cs | 10 +- .../DisplayNameFormatters.cs | 14 ++ .../EquatableReadOnlyList.cs | 53 +++++ .../Immediate.Cache.Generators.csproj | 37 ++++ .../ImmediateCacheGenerator.Models.cs | 22 ++ .../ImmediateCacheGenerator.Render.cs | 76 +++++++ .../ImmediateCacheGenerator.Transform.cs | 31 +++ .../ImmediateCacheGenerator.cs | 81 ++++++++ .../Templates/ApplicationCache.sbntxt | 27 +++ .../ServiceCollectionExtensions.sbntxt | 35 ++++ ...cationCacheBase.cs => ApplicationCache.cs} | 4 +- .../CacheForAttribute.cs | 14 ++ .../Immediate.Cache.Shared.csproj | 6 +- src/Immediate.Cache.Shared/Owned.cs | 2 +- src/Immediate.Cache.Shared/OwnedScope.cs | 2 +- src/Immediate.Cache/Immediate.Cache.csproj | 21 +- .../ApplicationCacheTests.cs | 1 + .../DelayGetValue.cs | 6 +- .../DelayGetValueCache.cs | 3 +- .../GetValueCache.cs | 3 +- .../GeneratorTests/CacheGenerationTests.cs | 195 ++++++++++++++++++ .../GeneratorTests/GeneratorTestHelper.cs | 156 ++++++++++++++ .../ImmediateAssemblyIdentifierTests.cs | 57 +++++ ....ServiceCollectionExtensions.g.verified.cs | 22 ++ ....ServiceCollectionExtensions.g.verified.cs | 22 ++ ....ServiceCollectionExtensions.g.verified.cs | 22 ++ ...#IC.Dummy.GetUsersQueryCache.g.verified.cs | 25 +++ ....ServiceCollectionExtensions.g.verified.cs | 29 +++ ...#IC.Dummy.GetUsersQueryCache.g.verified.cs | 25 +++ ....ServiceCollectionExtensions.g.verified.cs | 29 +++ .../Immediate.Cache.Tests.csproj | 32 +-- .../ModuleInitializer.cs | 16 ++ .../OwnedDisposableScopeSuppressorTests.cs | 14 +- .../SuppressorTests/SuppressorTestHelpers.cs | 2 +- tests/Immediate.Cache.Tests/Utility.cs | 21 +- 44 files changed, 1264 insertions(+), 101 deletions(-) create mode 100644 src/Common/ITypeSymbolExtensions.cs create mode 100644 src/Common/SyntaxExtensions.cs create mode 100644 src/Common/Utility.cs create mode 100644 src/Immediate.Cache.Generators/DisplayNameFormatters.cs create mode 100644 src/Immediate.Cache.Generators/EquatableReadOnlyList.cs create mode 100644 src/Immediate.Cache.Generators/Immediate.Cache.Generators.csproj create mode 100644 src/Immediate.Cache.Generators/ImmediateCacheGenerator.Models.cs create mode 100644 src/Immediate.Cache.Generators/ImmediateCacheGenerator.Render.cs create mode 100644 src/Immediate.Cache.Generators/ImmediateCacheGenerator.Transform.cs create mode 100644 src/Immediate.Cache.Generators/ImmediateCacheGenerator.cs create mode 100644 src/Immediate.Cache.Generators/Templates/ApplicationCache.sbntxt create mode 100644 src/Immediate.Cache.Generators/Templates/ServiceCollectionExtensions.sbntxt rename src/Immediate.Cache.Shared/{ApplicationCacheBase.cs => ApplicationCache.cs} (98%) create mode 100644 src/Immediate.Cache.Shared/CacheForAttribute.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/CacheGenerationTests.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/GeneratorTestHelper.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/ImmediateAssemblyIdentifierTests.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.InvalidHandlerReturn_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.NotHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.StaticHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.Dummy.GetUsersQueryCache.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.ServiceCollectionExtensions.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.Dummy.GetUsersQueryCache.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.ServiceCollectionExtensions.g.verified.cs create mode 100644 tests/Immediate.Cache.Tests/ModuleInitializer.cs diff --git a/.editorconfig b/.editorconfig index 4e52df2..f555214 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,17 @@ tab_width = 4 indent_size = 4 charset = utf-8 +# 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_size = 2 + +# Code files +[*.cs] ### Naming rules: ### @@ -52,8 +63,7 @@ dotnet_naming_rule.static_fields_should_be_pascal_case.style = static_field_styl dotnet_naming_symbols.static_fields.applicable_kinds = field dotnet_naming_symbols.static_fields.required_modifiers = static -dotnet_naming_style.static_field_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = s_ +dotnet_naming_style.static_field_style.capitalization = pascal_case # Instance fields are camelCase and start with _ dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion @@ -150,20 +160,6 @@ dotnet_style_readonly_field = true:warning # New-line preferences dotnet_style_allow_multiple_blank_lines_experimental = false:warning dotnet_style_allow_statement_immediately_after_block_experimental = false:warning -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = false:warning -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = false:warning - -# 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_size = 2 - -# Code files -[*.cs] ## C# style settings: @@ -195,7 +191,7 @@ csharp_style_expression_bodied_constructors = false:none csharp_style_expression_bodied_operators = false:none # Prefer local method constructs to have a block body -csharp_style_expression_bodied_local_functions = true:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion # Prefer property-like constructs to have an expression-body csharp_style_expression_bodied_properties = true:suggestion @@ -288,16 +284,17 @@ dotnet_diagnostic.CS1998.severity = none # CS1998: Async method l 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 +dotnet_diagnostic.CA1034.severity = none # CA1034: Nested types should not be visible dotnet_diagnostic.CA1515.severity = none # CA1515: Consider making public types internal +dotnet_diagnostic.CA1708.severity = none # CA1708: Identifiers should differ by more than case +dotnet_diagnostic.CA1716.severity = none # CA1716: Identifiers should not match keywords +dotnet_diagnostic.IDE0390.severity = none # IDE0390: Make method synchronous # Meziantou.Analyzers MA0053.public_class_should_be_sealed = true @@ -307,13 +304,24 @@ dotnet_diagnostic.MA0004.severity = none dotnet_diagnostic.MA0048.severity = none dotnet_diagnostic.MA0051.severity = none dotnet_diagnostic.MA0053.severity = warning +dotnet_diagnostic.MA0190.severity = none [src/Immediate.Cache.Shared/**.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) # Async dotnet_diagnostic.CA2007.severity = error # CA2007: Consider calling ConfigureAwait on the awaited task + +[tests/**.cs] + +dotnet_diagnostic.CA1707.severity = none # CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1724.severity = none # CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1822.severity = none # CA1822: Mark members as static +dotnet_diagnostic.CA1873.severity = none # CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled + +dotnet_diagnostic.xUnit2029.severity = none # xUnit2029: Do not use Assert.Empty to check if a value does not exist in a collection diff --git a/Directory.Build.props b/Directory.Build.props index 97d1a94..49d1b63 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,6 +22,14 @@ + $(Polyfill)|T:System.Index + $(Polyfill)|T:System.Range + $(Polyfill)|T:System.Diagnostics.CodeAnalysis + $(Polyfill)|T:System.Runtime.CompilerServices.CallerArgumentExpressionAttribute + $(Polyfill)|T:System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute + $(Polyfill)|T:System.Runtime.CompilerServices.IsExternalInit + $(Polyfill)|T:System.Runtime.CompilerServices.RequiredMemberAttribute + $(Polyfill)|T:System.Runtime.CompilerServices.SkipLocalsInitAttribute $(Polyfill)|T:System.Threading.Lock $(Polyfill) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0721d7a..d0a0f2d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -13,6 +14,9 @@ + + + diff --git a/Immediate.Cache.slnx b/Immediate.Cache.slnx index 9daf63d..0f9cda7 100644 --- a/Immediate.Cache.slnx +++ b/Immediate.Cache.slnx @@ -16,6 +16,7 @@ + diff --git a/readme.md b/readme.md index 806ff50..79fc349 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,9 @@ Immediate.Cache is a collection of classes that simplify caching responses from ### Creating a Cache -Create a subclass of `ApplicationCacheBase`, which will serve as the cache for a particular handler. An example: +Create a class and apply the `[CacheFor<>]` attribute, targeting a handler. Add a `TransformKey` method to transform a +request into a cache key. For example: + ```cs [Handler] public static partial class GetValue @@ -33,13 +35,8 @@ public static partial class GetValue ) => ValueTask.FromResult(new Response(query.Value)); } -public sealed class GetValueCache( - IMemoryCache memoryCache, - Owned> ownedHandler -) : ApplicationCacheBase( - memoryCache, - ownedHandler -) +[CacheFor] +public sealed class GetValueCache { protected override string TransformKey(GetValue.Query request) => $"GetValue(query: {request.Value})"; @@ -48,24 +45,15 @@ public sealed class GetValueCache( In this case, the `GetValueCache` class will serve as a cache for the `GetValue` IH handler. -### Register the Cache with DI - -In your `Program.cs` file: +### Adding generated caches to the `IServiceCollection` collection -* Ensure that Memory Cache is registered, by calling: -```cs -services.AddMemoryCache(); -``` +In your `Program.cs`, add a call to `services.AddXxxCaches()`, where Xxx is the application identifier. By default, +this is the short form of the assembly name. For example: -* Register `Owned<>` as a singleton -```cs -services.AddSingleton(typeof(Owned<>)); -``` +* For a project named `Web`, it will be `services.AddWebCaches()` +* For a project named `Application.Web`, it will be `services.AddApplicationWebCaches()` -* Register your cache service(s) as a singleton(s) -```cs -services.AddSingleton(); -``` +However, this name can be overridden using `[assembly: ImmediateAssemblyIdentifierAttribute("SomeIdentifier")]`. ### Retrieve Data From the Cache diff --git a/src/Common/ITypeSymbolExtensions.cs b/src/Common/ITypeSymbolExtensions.cs new file mode 100644 index 0000000..328be60 --- /dev/null +++ b/src/Common/ITypeSymbolExtensions.cs @@ -0,0 +1,114 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Immediate.Cache; + +internal static class ITypeSymbolExtensions +{ + extension([NotNullWhen(true)] ITypeSymbol? typeSymbol) + { + public bool IsImmediateAssemblyIdentifierAttribute => + typeSymbol is INamedTypeSymbol + { + Arity: 0, + Name: "ImmediateAssemblyIdentifierAttribute", + ContainingNamespace.IsImmediateHandlersShared: true, + }; + + public bool IsHandlerAttribute => + typeSymbol is INamedTypeSymbol + { + Name: "HandlerAttribute", + ContainingNamespace.IsImmediateHandlersShared: true, + }; + } + + extension(INamedTypeSymbol typeSymbol) + { + public bool GetValidHandleMethod([NotNullWhen(true)] out ITypeSymbol? requestType, [NotNullWhen(true)] out ITypeSymbol? responseType) + { + requestType = null; + responseType = null; + + if (!typeSymbol.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute)) + return false; + + if (typeSymbol + .GetMembers() + .OfType() + .Where(m => m.Name is "Handle" or "HandleAsync") + .Take(2) + .ToList() is not [var handleMethod]) + { + return false; + } + + // must have request type + if (handleMethod.Parameters is not [{ Type: ITypeSymbol parameterType }, ..]) + return false; + + if (handleMethod.ReturnType is not INamedTypeSymbol + { + Arity: 1, + Name: "ValueTask", + ContainingNamespace.IsSystemThreadingTasks: true, + TypeArguments: [ITypeSymbol returnType], + }) + { + return false; + } + + requestType = parameterType; + responseType = returnType; + return true; + } + } + + extension(INamespaceSymbol namespaceSymbol) + { + public bool IsImmediateCacheShared => + namespaceSymbol is + { + Name: "Shared", + ContainingNamespace: + { + Name: "Cache", + ContainingNamespace: + { + Name: "Immediate", + ContainingNamespace.IsGlobalNamespace: true, + }, + }, + }; + + public bool IsImmediateHandlersShared => + namespaceSymbol is + { + Name: "Shared", + ContainingNamespace: + { + Name: "Handlers", + ContainingNamespace: + { + Name: "Immediate", + ContainingNamespace.IsGlobalNamespace: true, + }, + }, + }; + + public bool IsSystemThreadingTasks => + namespaceSymbol is + { + Name: "Tasks", + ContainingNamespace: + { + Name: "Threading", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true, + }, + }, + }; + } +} diff --git a/src/Common/SyntaxExtensions.cs b/src/Common/SyntaxExtensions.cs new file mode 100644 index 0000000..9737d42 --- /dev/null +++ b/src/Common/SyntaxExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Immediate.Cache; + +internal static class SyntaxExtensions +{ + extension(MemberDeclarationSyntax mds) + { + public bool IsStatic => mds.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)); + } +} diff --git a/src/Common/Utility.cs b/src/Common/Utility.cs new file mode 100644 index 0000000..cd12d38 --- /dev/null +++ b/src/Common/Utility.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Cache; + +internal static class Utility +{ + public static string? NullIf(this string value, string check) => + value.Equals(check, StringComparison.Ordinal) ? null : value; + + public static IncrementalValuesProvider WhereNotNull(this IncrementalValuesProvider values) + where T : class => values.Where(x => x is not null)!; +} diff --git a/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj index 551c261..18975e9 100644 --- a/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj +++ b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj @@ -1,15 +1,19 @@ - netstandard2.0 - true - true + netstandard2.0 + true + true - - - + + + + + + + diff --git a/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs index 1319bb8..d246793 100644 --- a/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs +++ b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs @@ -54,15 +54,7 @@ public override void ReportSuppressions(SuppressionAnalysisContext context) { Arity: 1, Name: "Owned", - ContainingNamespace: - { - Name: "Cache", - ContainingNamespace: - { - Name: "Immediate", - ContainingNamespace.IsGlobalNamespace: true, - }, - }, + ContainingNamespace.IsImmediateCacheShared: true, }) { continue; diff --git a/src/Immediate.Cache.Generators/DisplayNameFormatters.cs b/src/Immediate.Cache.Generators/DisplayNameFormatters.cs new file mode 100644 index 0000000..6adbaca --- /dev/null +++ b/src/Immediate.Cache.Generators/DisplayNameFormatters.cs @@ -0,0 +1,14 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Cache.Generators; + +internal static class DisplayNameFormatters +{ + public static readonly SymbolDisplayFormat FullyQualifiedWithNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions( + SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + +} diff --git a/src/Immediate.Cache.Generators/EquatableReadOnlyList.cs b/src/Immediate.Cache.Generators/EquatableReadOnlyList.cs new file mode 100644 index 0000000..8774136 --- /dev/null +++ b/src/Immediate.Cache.Generators/EquatableReadOnlyList.cs @@ -0,0 +1,53 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Cache.Generators; + +[ExcludeFromCodeCoverage] +public static class EquatableReadOnlyList +{ + public static EquatableReadOnlyList ToEquatableReadOnlyList(this IEnumerable enumerable) + => new(enumerable is IReadOnlyList l ? l : [.. enumerable]); +} + +/// +/// 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.Cache.Generators/Immediate.Cache.Generators.csproj b/src/Immediate.Cache.Generators/Immediate.Cache.Generators.csproj new file mode 100644 index 0000000..1812740 --- /dev/null +++ b/src/Immediate.Cache.Generators/Immediate.Cache.Generators.csproj @@ -0,0 +1,37 @@ + + + + netstandard2.0 + true + true + $(NoWarn);CA1716 + + + + + + + + + + + + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + diff --git a/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Models.cs b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Models.cs new file mode 100644 index 0000000..0d8cc53 --- /dev/null +++ b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Models.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Immediate.Cache.Generators; + +public sealed partial class ImmediateCacheGenerator +{ + [ExcludeFromCodeCoverage] + private sealed record AssemblyDefaults + { + public required string AssemblyName { get; init; } + public required string RootNamespace { get; init; } + } + + [ExcludeFromCodeCoverage] + private sealed record CacheDefinition + { + public required string? Namespace { get; init; } + public required string ClassName { get; init; } + public required string RequestType { get; init; } + public required string ResponseType { get; init; } + } +} diff --git a/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Render.cs b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Render.cs new file mode 100644 index 0000000..d795ad0 --- /dev/null +++ b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Render.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using Scriban; + +namespace Immediate.Cache.Generators; + +public sealed partial class ImmediateCacheGenerator +{ + private static readonly Template ServiceCollectionExtensionsTemplate = GetTemplate("ServiceCollectionExtensions"); + private static readonly Template ApplicationCacheTemplate = GetTemplate("ApplicationCache"); + + private static void RenderServiceCollectionExtensions( + IncrementalGeneratorInitializationContext context, + IncrementalValueProvider assemblyDefaults, + IncrementalValuesProvider caches + ) + { + context.RegisterSourceOutput( + assemblyDefaults.Combine(caches.Collect()), + (context, x) => + { + var source = ServiceCollectionExtensionsTemplate + .Render(new + { + x.Left.AssemblyName, + x.Left.RootNamespace, + + Caches = x.Right, + + Version = ThisAssembly.InformationalVersion, + }); + + context.CancellationToken.ThrowIfCancellationRequested(); + context.AddSource("IC.ServiceCollectionExtensions.g.cs", source); + } + ); + } + + private static void RenderCaches( + IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider caches + ) + { + context.RegisterSourceOutput( + caches, + (context, cache) => + { + var source = ApplicationCacheTemplate + .Render(new + { + cache.Namespace, + cache.ClassName, + cache.RequestType, + cache.ResponseType, + + Version = ThisAssembly.InformationalVersion, + }); + + context.CancellationToken.ThrowIfCancellationRequested(); + context.AddSource($"IC.{cache.Namespace}.{cache.ClassName}.g.cs", source); + } + ); + } + + private static Template GetTemplate(string name) + { + using var stream = Assembly + .GetExecutingAssembly() + .GetManifestResourceStream( + $"Immediate.Cache.Generators.Templates.{name}.sbntxt" + ); + + using var reader = new StreamReader(stream); + return Template.Parse(reader.ReadToEnd()); + } +} diff --git a/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Transform.cs b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Transform.cs new file mode 100644 index 0000000..bab15f1 --- /dev/null +++ b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.Transform.cs @@ -0,0 +1,31 @@ +using Microsoft.CodeAnalysis; + +namespace Immediate.Cache.Generators; + +public sealed partial class ImmediateCacheGenerator +{ + private static CacheDefinition? TransformCacheDefinition(GeneratorAttributeSyntaxContext context, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + if (context.TargetSymbol is not INamedTypeSymbol { ContainingType: null } targetSymbol) + return null; + + if (context.Attributes is not [{ AttributeClass.TypeArguments: [INamedTypeSymbol { IsStatic: false } handlerSymbol] }]) + return null; + + var @namespace = targetSymbol.ContainingNamespace.ToString().NullIf(""); + var name = targetSymbol.Name; + + if (!handlerSymbol.GetValidHandleMethod(out var requestType, out var responseType)) + return null; + + return new CacheDefinition() + { + Namespace = @namespace, + ClassName = name, + RequestType = requestType.ToDisplayString(DisplayNameFormatters.FullyQualifiedWithNullableFormat), + ResponseType = responseType.ToDisplayString(DisplayNameFormatters.FullyQualifiedWithNullableFormat), + }; + } +} diff --git a/src/Immediate.Cache.Generators/ImmediateCacheGenerator.cs b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.cs new file mode 100644 index 0000000..453b58d --- /dev/null +++ b/src/Immediate.Cache.Generators/ImmediateCacheGenerator.cs @@ -0,0 +1,81 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Immediate.Cache.Generators; + +[Generator] +public sealed partial class ImmediateCacheGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var assemblyDefaults = GetAssemblyDefaults(context); + + var caches = ProcessCaches(context); + + RenderServiceCollectionExtensions(context, assemblyDefaults, caches); + } + + private static IncrementalValueProvider GetAssemblyDefaults(IncrementalGeneratorInitializationContext context) + { + var assemblyName = context.CompilationProvider + .Select((cp, _) => cp.GetAssemblyIdentifier()) + .WithTrackingName("AssemblyName"); + + var @namespace = context + .AnalyzerConfigOptionsProvider + .Select( + (c, _) => c.GlobalOptions + .TryGetValue("build_property.rootnamespace", out var ns) + ? ns : "" + ) + .WithTrackingName("RootNamespace"); + + var assemblyDefaults = assemblyName + .Combine(@namespace) + .Select((x, _) => new AssemblyDefaults + { + AssemblyName = x.Left, + RootNamespace = x.Right, + }) + .WithTrackingName("AssemblyDefaults"); + + return assemblyDefaults; + } + + private static IncrementalValuesProvider ProcessCaches(IncrementalGeneratorInitializationContext context) + { + var caches = context.SyntaxProvider + .ForAttributeWithMetadataName( + "Immediate.Cache.Shared.CacheForAttribute`1", + (node, _) => node is ClassDeclarationSyntax { IsStatic: false }, + TransformCacheDefinition + ) + .WhereNotNull() + .WithTrackingName("CacheDefinitions"); + + RenderCaches(context, caches); + + return caches; + } +} + +file static class Extensions +{ + public static string GetAssemblyIdentifier(this Compilation compilation) + { + if (compilation.Assembly.GetAttributes() + .FirstOrDefault(a => a.AttributeClass.IsImmediateAssemblyIdentifierAttribute) + is { ConstructorArguments: [{ Value: string { Length: >= 1 } identifier }] } + && identifier[0] != '@' + && SyntaxFacts.IsValidIdentifier(identifier)) + { + return identifier; + } + + return (compilation.AssemblyName ?? string.Empty) + .Replace(".", string.Empty) + .Replace(" ", string.Empty) + .Trim(); + } +} diff --git a/src/Immediate.Cache.Generators/Templates/ApplicationCache.sbntxt b/src/Immediate.Cache.Generators/Templates/ApplicationCache.sbntxt new file mode 100644 index 0000000..8b660b7 --- /dev/null +++ b/src/Immediate.Cache.Generators/Templates/ApplicationCache.sbntxt @@ -0,0 +1,27 @@ +// +#nullable enable + +#pragma warning disable CS1591 + +{{~ if !string.empty namespace ~}} +namespace {{ namespace }}; + +{{~ end ~}} +partial class {{ class_name }} : global::Immediate.Cache.Shared.ApplicationCache< + {{ request_type }}, + {{ response_type }} +> +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Immediate.Cache", "{{ version }}")] + public {{ class_name }}( + global::Microsoft.Extensions.Caching.Memory.IMemoryCache memoryCache, + global::Immediate.Cache.Shared.Owned< + global::Immediate.Handlers.Shared.IHandler< + {{ request_type }}, + {{ response_type }} + > + > ownedHandler + ) : base(memoryCache, ownedHandler) + { + } +} diff --git a/src/Immediate.Cache.Generators/Templates/ServiceCollectionExtensions.sbntxt b/src/Immediate.Cache.Generators/Templates/ServiceCollectionExtensions.sbntxt new file mode 100644 index 0000000..eb7ba17 --- /dev/null +++ b/src/Immediate.Cache.Generators/Templates/ServiceCollectionExtensions.sbntxt @@ -0,0 +1,35 @@ +// +#nullable enable + +#pragma warning disable CS1591 + +{{~ if !string.empty root_namespace ~}} +namespace {{ root_namespace }}; + +{{~ end ~}} +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Immediate.Cache", "{{ version }}")] +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection Add{{ assembly_name }}Caches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + {{~ for c in caches ~}} + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::{{~ if !string.empty c.namespace; c.namespace; "."; end }}{{ c.class_name }}) + ) + ); + + {{~ end ~}} + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/src/Immediate.Cache.Shared/ApplicationCacheBase.cs b/src/Immediate.Cache.Shared/ApplicationCache.cs similarity index 98% rename from src/Immediate.Cache.Shared/ApplicationCacheBase.cs rename to src/Immediate.Cache.Shared/ApplicationCache.cs index 4cb109b..18ba9e8 100644 --- a/src/Immediate.Cache.Shared/ApplicationCacheBase.cs +++ b/src/Immediate.Cache.Shared/ApplicationCache.cs @@ -2,7 +2,7 @@ using Immediate.Handlers.Shared; using Microsoft.Extensions.Caching.Memory; -namespace Immediate.Cache; +namespace Immediate.Cache.Shared; /// /// Base class for caching the results of an . @@ -19,7 +19,7 @@ namespace Immediate.Cache; /// /// The handler from which to cache data /// -public abstract class ApplicationCacheBase( +public abstract class ApplicationCache( IMemoryCache memoryCache, Owned> handler ) diff --git a/src/Immediate.Cache.Shared/CacheForAttribute.cs b/src/Immediate.Cache.Shared/CacheForAttribute.cs new file mode 100644 index 0000000..e519194 --- /dev/null +++ b/src/Immediate.Cache.Shared/CacheForAttribute.cs @@ -0,0 +1,14 @@ +using Immediate.Handlers.Shared; + +namespace Immediate.Cache.Shared; + +/// +/// Apply to a -attributed class to generate +/// a cache wrapper for the handler. +/// +/// +/// The handler class for which to generate a cache. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class CacheForAttribute : Attribute + where THandler : class; diff --git a/src/Immediate.Cache.Shared/Immediate.Cache.Shared.csproj b/src/Immediate.Cache.Shared/Immediate.Cache.Shared.csproj index 16fe36f..1cb1217 100644 --- a/src/Immediate.Cache.Shared/Immediate.Cache.Shared.csproj +++ b/src/Immediate.Cache.Shared/Immediate.Cache.Shared.csproj @@ -1,11 +1,7 @@ - - Immediate.Cache - - - runtime-async=on + runtime-async=on diff --git a/src/Immediate.Cache.Shared/Owned.cs b/src/Immediate.Cache.Shared/Owned.cs index a2357d7..6d3b27a 100644 --- a/src/Immediate.Cache.Shared/Owned.cs +++ b/src/Immediate.Cache.Shared/Owned.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -namespace Immediate.Cache; +namespace Immediate.Cache.Shared; /// /// A factory for creating a scope containing a strong-type service as it's root. diff --git a/src/Immediate.Cache.Shared/OwnedScope.cs b/src/Immediate.Cache.Shared/OwnedScope.cs index a9eab22..679c50c 100644 --- a/src/Immediate.Cache.Shared/OwnedScope.cs +++ b/src/Immediate.Cache.Shared/OwnedScope.cs @@ -1,4 +1,4 @@ -namespace Immediate.Cache; +namespace Immediate.Cache.Shared; /// /// Represents a container for a scope and a scoped service that is rooted by the scope. diff --git a/src/Immediate.Cache/Immediate.Cache.csproj b/src/Immediate.Cache/Immediate.Cache.csproj index f3196c2..1da280b 100644 --- a/src/Immediate.Cache/Immediate.Cache.csproj +++ b/src/Immediate.Cache/Immediate.Cache.csproj @@ -24,26 +24,35 @@ + + + + Include="$(PkgScriban)/lib/netstandard2.0/Scriban.dll" + Pack="true" + PackagePath="analyzers/roslyn4.8/dotnet/cs" + Condition=" '$(TargetFramework)' == 'net8.0' " /> + Include="../Immediate.Cache.Shared/bin/$(Configuration)/$(TargetFramework)/Immediate.Cache.Shared.dll" + Pack="true" + PackagePath="lib/$(TargetFramework)" /> + + diff --git a/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs b/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs index da522f7..3bb9f48 100644 --- a/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs +++ b/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs @@ -1,3 +1,4 @@ +using Immediate.Cache.Shared; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Xunit; diff --git a/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs b/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs index 4bd12be..535c5cf 100644 --- a/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs +++ b/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs @@ -21,7 +21,7 @@ public sealed class Query public sealed record Response(int Value, bool ExecutedHandler, Guid RandomValue); - private static readonly Lock s_lock = new(); + private static readonly Lock Lock = new(); private static async ValueTask HandleAsync( Query query, @@ -37,14 +37,14 @@ CancellationToken token _ = query.WaitForTestToStartExecuting.TrySetResult(); await query.WaitForTestToContinueOperation.Task.WaitAsync(token); - lock (s_lock) + lock (Lock) query.TimesExecuted++; return new(query.Value, ExecutedHandler: true, RandomValue: Guid.NewGuid()); } catch { - lock (s_lock) + lock (Lock) query.TimesCancelled++; throw; } diff --git a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs index a3555ca..1bd6632 100644 --- a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs +++ b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Immediate.Cache.Shared; using Immediate.Handlers.Shared; using Microsoft.Extensions.Caching.Memory; @@ -7,7 +8,7 @@ namespace Immediate.Cache.FunctionalTests; public sealed class DelayGetValueCache( IMemoryCache memoryCache, Owned> ownedHandler -) : ApplicationCacheBase( +) : ApplicationCache( memoryCache, ownedHandler ) diff --git a/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs b/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs index 18f7f48..f29d0dd 100644 --- a/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs +++ b/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Immediate.Cache.Shared; using Immediate.Handlers.Shared; using Microsoft.Extensions.Caching.Memory; @@ -7,7 +8,7 @@ namespace Immediate.Cache.FunctionalTests; public sealed class GetValueCache( IMemoryCache memoryCache, Owned> ownedHandler -) : ApplicationCacheBase( +) : ApplicationCache( memoryCache, ownedHandler ) diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/CacheGenerationTests.cs b/tests/Immediate.Cache.Tests/GeneratorTests/CacheGenerationTests.cs new file mode 100644 index 0000000..1e97e28 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/CacheGenerationTests.cs @@ -0,0 +1,195 @@ +using static Immediate.Cache.Tests.Utility; + +namespace Immediate.Cache.Tests.GeneratorTests; + +public sealed class CacheGenerationTests +{ + [Fact] + public async Task ValidCache() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class GetUsersQueryCache + { + protected override string TransformKey(GetUsersQuery.Query request) => "Test"; + } + + [Handler] + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """ + ); + + Assert.Equal( + [ + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.Dummy.GetUsersQueryCache.g.cs", + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.ServiceCollectionExtensions.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.Dummy.GetUsersQuery.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(t => t.FilePath.Replace('\\', '/')) + ); + + _ = await VerifyIgnoreImmediateHandlers(result); + } + + [Fact] + public async Task NotHandler_DoesNotGenerate() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class GetUsersQueryCache + { + } + + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """, + skippedSteps: ["CacheDefinitions"] + ); + + Assert.Equal( + [ + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(t => t.FilePath.Replace('\\', '/')) + ); + + _ = await VerifyIgnoreImmediateHandlers(result); + } + + [Fact] + public async Task StaticHandler_DoesNotGenerate() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class GetUsersQueryCache + { + } + + [Handler] + public static partial class GetUsersQuery + { + public record Query; + public record Response; + + private static async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """, + skippedSteps: ["CacheDefinitions"] + ); + + Assert.Equal( + [ + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.ServiceCollectionExtensions.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.Dummy.GetUsersQuery.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(t => t.FilePath.Replace('\\', '/')) + ); + + _ = await VerifyIgnoreImmediateHandlers(result); + } + + [Fact] + public async Task InvalidHandlerReturn_DoesNotGenerate() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class GetUsersQueryCache + { + } + + [Handler] + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + } + } + """, + skippedSteps: ["CacheDefinitions"] + ); + + Assert.Equal( + [ + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.ServiceCollectionExtensions.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.Dummy.GetUsersQuery.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(t => t.FilePath.Replace('\\', '/')) + ); + + _ = await VerifyIgnoreImmediateHandlers(result); + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/GeneratorTestHelper.cs b/tests/Immediate.Cache.Tests/GeneratorTests/GeneratorTestHelper.cs new file mode 100644 index 0000000..d63b73e --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/GeneratorTestHelper.cs @@ -0,0 +1,156 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Immediate.Cache.Generators; +using Immediate.Handlers.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Immediate.Cache.Tests.GeneratorTests; + +public static class GeneratorTestHelper +{ + public static GeneratorDriverRunResult RunGenerator( + [StringSyntax("c#-test")] string source, + params ReadOnlySpan skippedSteps + ) => RunGenerator(source, LanguageVersion.CSharp13, skippedSteps); + + public static GeneratorDriverRunResult RunGenerator( + [StringSyntax("c#-test")] string source, + LanguageVersion languageVersion, + params ReadOnlySpan skippedSteps + ) + { + var parseOptions = new CSharpParseOptions(languageVersion); + + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + parseOptions, + cancellationToken: TestContext.Current.CancellationToken + ); + + var compilation = CSharpCompilation.Create( + assemblyName: "Tests", + syntaxTrees: [syntaxTree], + references: + [ + ..Utility.NetCoreAssemblies, + ..Utility.GetAdditionalReferences(), + ], + options: new( + outputKind: OutputKind.DynamicallyLinkedLibrary + ) + ); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [new ImmediateCacheGenerator().AsSourceGenerator(), new ImmediateHandlersGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) + ); + + driver = RunGenerator(driver, compilation); + var result = driver.GetRunResult(); + + VerifyIncrementality(driver, compilation, parseOptions, skippedSteps); + + return result; + } + + private static GeneratorDriver RunGenerator( + GeneratorDriver driver, + Compilation compilation + ) + { + driver = driver + .RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var diagnostics, + TestContext.Current.CancellationToken + ); + + Assert.Empty( + outputCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) + // ignore attempt to use a `static` handler type; making sure we don't generate in invalid scenario + .Where(d => d is not { Id: "CS0718" }) + ); + + Assert.Empty(diagnostics); + return driver; + } + + private static void VerifyIncrementality( + GeneratorDriver driver, + Compilation compilation, + CSharpParseOptions parseOptions, + ReadOnlySpan skippedSteps + ) + { + var clone = compilation.Clone().AddSyntaxTrees( + CSharpSyntaxTree.ParseText( + "// dummy", + parseOptions, + cancellationToken: TestContext.Current.CancellationToken + ) + ); + + driver = RunGenerator(driver, clone); + + if ( + driver.GetRunResult() is not + { + Results: + [ + { + TrackedOutputSteps: { } outputSteps, + TrackedSteps: { } trackedSteps, + }, + _ + ], + } + ) + { + Assert.Fail("Unable to verify incrementality."); + return; + } + + foreach (var (_, step) in outputSteps) + AssertSteps(step); + + foreach (var step in TrackedSteps) + { + if (skippedSteps.Contains(step)) + { + if (trackedSteps.ContainsKey(step)) + Assert.Fail($"Step `{step}` should have been skipped, but is present."); + } + else + { + if (!trackedSteps.TryGetValue(step, out var outputs)) + Assert.Fail($"Step `{step}` expected, but is missing."); + + AssertSteps(outputs); + } + } + } + + private static ReadOnlySpan TrackedSteps => + new string[] + { + "AssemblyName", + "RootNamespace", + "AssemblyDefaults", + + "CacheDefinitions", + }; + + private static void AssertSteps( + ImmutableArray steps + ) + { + var outputs = steps.SelectMany(o => o.Outputs); + + Assert.All(outputs, o => Assert.True(o.Reason is IncrementalStepRunReason.Unchanged or IncrementalStepRunReason.Cached)); + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/ImmediateAssemblyIdentifierTests.cs b/tests/Immediate.Cache.Tests/GeneratorTests/ImmediateAssemblyIdentifierTests.cs new file mode 100644 index 0000000..8ba394b --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/ImmediateAssemblyIdentifierTests.cs @@ -0,0 +1,57 @@ +using static Immediate.Cache.Tests.Utility; + +namespace Immediate.Cache.Tests.GeneratorTests; + +public sealed class ImmediateAssemblyIdentifierTests +{ + [Fact] + public async Task ImmediateAssemblyIdentifierOverridesAssemblyName() + { + var result = GeneratorTestHelper.RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + [assembly: ImmediateAssemblyIdentifier("Custom")] + + namespace Dummy; + + [CacheFor] + public sealed partial class GetUsersQueryCache + { + protected override string TransformKey(GetUsersQuery.Query request) => "Test"; + } + + [Handler] + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """ + ); + + Assert.Equal( + [ + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.Dummy.GetUsersQueryCache.g.cs", + "Immediate.Cache.Generators/Immediate.Cache.Generators.ImmediateCacheGenerator/IC.ServiceCollectionExtensions.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.Dummy.GetUsersQuery.g.cs", + "Immediate.Handlers.Generators/Immediate.Handlers.Generators.ImmediateHandlersGenerator/IH.ServiceCollectionExtensions.g.cs", + ], + result.GeneratedTrees.Select(t => t.FilePath.Replace('\\', '/')) + ); + + _ = await VerifyIgnoreImmediateHandlers(result); + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.InvalidHandlerReturn_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.InvalidHandlerReturn_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 0000000..6f3ae09 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.InvalidHandlerReturn_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: IC.ServiceCollectionExtensions.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestsCaches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.NotHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.NotHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 0000000..6f3ae09 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.NotHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: IC.ServiceCollectionExtensions.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestsCaches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.StaticHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.StaticHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 0000000..6f3ae09 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.StaticHandler_DoesNotGenerate#IC.ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: IC.ServiceCollectionExtensions.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestsCaches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.Dummy.GetUsersQueryCache.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.Dummy.GetUsersQueryCache.g.verified.cs new file mode 100644 index 0000000..bf568f1 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.Dummy.GetUsersQueryCache.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: IC.Dummy.GetUsersQueryCache.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQueryCache : global::Immediate.Cache.Shared.ApplicationCache< + global::Dummy.GetUsersQuery.Query, + global::Dummy.GetUsersQuery.Response +> +{ + public GetUsersQueryCache( + global::Microsoft.Extensions.Caching.Memory.IMemoryCache memoryCache, + global::Immediate.Cache.Shared.Owned< + global::Immediate.Handlers.Shared.IHandler< + global::Dummy.GetUsersQuery.Query, + global::Dummy.GetUsersQuery.Response + > + > ownedHandler + ) : base(memoryCache, ownedHandler) + { + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 0000000..3fe4091 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/CacheGenerationTests.ValidCache#IC.ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,29 @@ +//HintName: IC.ServiceCollectionExtensions.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddTestsCaches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Dummy.GetUsersQueryCache) + ) + ); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.Dummy.GetUsersQueryCache.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.Dummy.GetUsersQueryCache.g.verified.cs new file mode 100644 index 0000000..bf568f1 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.Dummy.GetUsersQueryCache.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: IC.Dummy.GetUsersQueryCache.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +namespace Dummy; + +partial class GetUsersQueryCache : global::Immediate.Cache.Shared.ApplicationCache< + global::Dummy.GetUsersQuery.Query, + global::Dummy.GetUsersQuery.Response +> +{ + public GetUsersQueryCache( + global::Microsoft.Extensions.Caching.Memory.IMemoryCache memoryCache, + global::Immediate.Cache.Shared.Owned< + global::Immediate.Handlers.Shared.IHandler< + global::Dummy.GetUsersQuery.Query, + global::Dummy.GetUsersQuery.Response + > + > ownedHandler + ) : base(memoryCache, ownedHandler) + { + } +} diff --git a/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.ServiceCollectionExtensions.g.verified.cs b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.ServiceCollectionExtensions.g.verified.cs new file mode 100644 index 0000000..fb3c9d2 --- /dev/null +++ b/tests/Immediate.Cache.Tests/GeneratorTests/Snapshots/ImmediateAssemblyIdentifierTests.ImmediateAssemblyIdentifierOverridesAssemblyName#IC.ServiceCollectionExtensions.g.verified.cs @@ -0,0 +1,29 @@ +//HintName: IC.ServiceCollectionExtensions.g.cs +// +#nullable enable + +#pragma warning disable CS1591 + +public static partial class CacheServiceCollectionExtensions +{ + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddCustomCaches( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Dummy.GetUsersQueryCache) + ) + ); + + global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.Add( + services, + global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Singleton( + typeof(global::Immediate.Cache.Shared.Owned<>) + ) + ); + + return services; + } +} diff --git a/tests/Immediate.Cache.Tests/Immediate.Cache.Tests.csproj b/tests/Immediate.Cache.Tests/Immediate.Cache.Tests.csproj index 30468ea..f587110 100644 --- a/tests/Immediate.Cache.Tests/Immediate.Cache.Tests.csproj +++ b/tests/Immediate.Cache.Tests/Immediate.Cache.Tests.csproj @@ -1,7 +1,7 @@ - Exe + Exe <_SkipUpgradeNetAnalyzersNuGetWarning>true @@ -10,39 +10,45 @@ - - - - - + + + + + + - - + + + + + - + - + - + - + + + - + diff --git a/tests/Immediate.Cache.Tests/ModuleInitializer.cs b/tests/Immediate.Cache.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..916198f --- /dev/null +++ b/tests/Immediate.Cache.Tests/ModuleInitializer.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; + +namespace Immediate.Cache.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + VerifierSettings.AutoVerify(includeBuildServer: false); + VerifierSettings.ScrubLinesContaining("cs", comparison: StringComparison.Ordinal, "GeneratedCodeAttribute"); + UseSourceFileRelativeDirectory("Snapshots"); + + VerifySourceGenerators.Initialize(); + } +} diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs index af844fd..01aa43e 100644 --- a/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs +++ b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs @@ -18,7 +18,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -50,7 +50,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -84,7 +84,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -118,7 +118,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -151,7 +151,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -189,7 +189,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { @@ -226,7 +226,7 @@ await SuppressorTestHelpers using System; using System.Threading.Tasks; - using Immediate.Cache; + using Immediate.Cache.Shared; public sealed class Disposable : IDisposable, IAsyncDisposable { diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs index 3bdb75c..e7960c6 100644 --- a/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs +++ b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs @@ -97,7 +97,7 @@ public static CSharpSuppressorTest CreateSuppresso }, }; - test.TestState.AdditionalReferences.AddRange(Utility.GetMetadataReferences()); + test.TestState.AdditionalReferences.AddRange(Utility.GetAdditionalReferences()); return test; } diff --git a/tests/Immediate.Cache.Tests/Utility.cs b/tests/Immediate.Cache.Tests/Utility.cs index cafa8cc..340b363 100644 --- a/tests/Immediate.Cache.Tests/Utility.cs +++ b/tests/Immediate.Cache.Tests/Utility.cs @@ -1,9 +1,15 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Immediate.Cache.Shared; +using Immediate.Handlers.Shared; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; namespace Immediate.Cache.Tests; -internal static class Utility +internal static partial class Utility { #if NET8_0 public static ReferenceAssemblies ReferenceAssemblies => ReferenceAssemblies.Net.Net80; @@ -28,8 +34,19 @@ internal static class Utility #error .net version not yet implemented #endif - public static IEnumerable GetMetadataReferences() => + public static IEnumerable GetAdditionalReferences() => [ MetadataReference.CreateFromFile(typeof(Owned<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(HandlerAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IMemoryCache).Assembly.Location), ]; + + public static SettingsTask VerifyIgnoreImmediateHandlers(GeneratorDriverRunResult result, [CallerFilePath] string sourceFile = "") => + Verify(result, sourceFile: sourceFile) + .IgnoreGeneratedResult(gsr => ImmediateHandlersHintName().IsMatch(Path.GetFileName(gsr.HintName))); + + [GeneratedRegex(@"IH\..*\.g\.cs", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)] + private static partial Regex ImmediateHandlersHintName(); }