diff --git a/src/Common/ITypeSymbolExtensions.cs b/src/Common/ITypeSymbolExtensions.cs index 328be60..5b42065 100644 --- a/src/Common/ITypeSymbolExtensions.cs +++ b/src/Common/ITypeSymbolExtensions.cs @@ -18,20 +18,30 @@ typeSymbol is INamedTypeSymbol public bool IsHandlerAttribute => typeSymbol is INamedTypeSymbol { + Arity: 0, Name: "HandlerAttribute", ContainingNamespace.IsImmediateHandlersShared: true, }; + + public bool IsHandler => + typeSymbol is INamedTypeSymbol + && typeSymbol.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute); + + public bool IsCacheForAttribute => + typeSymbol is INamedTypeSymbol + { + Arity: 1, + Name: "CacheForAttribute", + ContainingNamespace.IsImmediateCacheShared: true, + }; } extension(INamedTypeSymbol typeSymbol) { - public bool GetValidHandleMethod([NotNullWhen(true)] out ITypeSymbol? requestType, [NotNullWhen(true)] out ITypeSymbol? responseType) + public IMethodSymbol? GetHandleMethod() { - requestType = null; - responseType = null; - - if (!typeSymbol.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute)) - return false; + if (!typeSymbol.IsHandler) + return null; if (typeSymbol .GetMembers() @@ -40,9 +50,20 @@ public bool GetValidHandleMethod([NotNullWhen(true)] out ITypeSymbol? requestTyp .Take(2) .ToList() is not [var handleMethod]) { - return false; + return null; } + return handleMethod; + } + + public bool GetValidHandleMethod([NotNullWhen(true)] out ITypeSymbol? requestType, [NotNullWhen(true)] out ITypeSymbol? responseType) + { + requestType = null; + responseType = null; + + if (typeSymbol.GetHandleMethod() is not { } handleMethod) + return false; + // must have request type if (handleMethod.Parameters is not [{ Type: ITypeSymbol parameterType }, ..]) return false; diff --git a/src/Immediate.Cache.Analyzers/AnalyzerReleases.Shipped.md b/src/Immediate.Cache.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..6a0cdec --- /dev/null +++ b/src/Immediate.Cache.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ +## Release 2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +IC0001 | ImmediateCache | Error | CacheForUsageAnalyzer +IC0002 | ImmediateCache | Error | CacheForUsageAnalyzer +IC0003 | ImmediateCache | Error | CacheForUsageAnalyzer diff --git a/src/Immediate.Cache.Analyzers/AnalyzerReleases.Unshipped.md b/src/Immediate.Cache.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Immediate.Cache.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1 @@ + diff --git a/src/Immediate.Cache.Analyzers/CacheForUsageAnalyzer.cs b/src/Immediate.Cache.Analyzers/CacheForUsageAnalyzer.cs new file mode 100644 index 0000000..51f3580 --- /dev/null +++ b/src/Immediate.Cache.Analyzers/CacheForUsageAnalyzer.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Immediate.Cache.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CacheForUsageAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor CacheMustNotBeNested = + new( + id: DiagnosticIds.IC0001CacheMustNotBeNested, + title: "Cache nesting is not allowed", + messageFormat: "Cache '{0}' must not be nested in another type", + category: "ImmediateCache", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Cache classes must not be nested in another type.", + customTags: [WellKnownDiagnosticTags.NotConfigurable] + ); + + public static readonly DiagnosticDescriptor TargetMustBeHandler = + new( + id: DiagnosticIds.IC0002TargetMustBeHandler, + title: "Cache Target must be a `[Handler]`", + messageFormat: "Cache Target class '{0}' is not marked as `[Handler]`", + category: "ImmediateCache", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "IC Caches explicitly wrap IH Handlers only.", + customTags: [WellKnownDiagnosticTags.NotConfigurable] + ); + + public static readonly DiagnosticDescriptor TargetHandlerMustReturnValue = + new( + id: DiagnosticIds.IC0003TargetHandlerMustReturnValue, + title: "Cache Target Handler must have return value", + messageFormat: "Cache Target class '{0}' must return a value", + category: "ImmediateCache", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Caching a non-value does not .", + customTags: [WellKnownDiagnosticTags.NotConfigurable] + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + [ + CacheMustNotBeNested, + TargetMustBeHandler, + TargetHandlerMustReturnValue, + ]); + + 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) + { + var token = context.CancellationToken; + token.ThrowIfCancellationRequested(); + + var cacheSymbol = (INamedTypeSymbol)context.Symbol; + + if (cacheSymbol.GetCacheTargetHandler() is not { } targetTypeSymbol) + return; + + if (cacheSymbol.ContainingType is not null) + { + context.ReportDiagnostic( + Diagnostic.Create( + CacheMustNotBeNested, + cacheSymbol.Locations[0], + cacheSymbol.Name) + ); + } + + if (targetTypeSymbol is not INamedTypeSymbol { IsHandler: true } handlerSymbol) + { + context.ReportDiagnostic( + Diagnostic.Create( + TargetMustBeHandler, + cacheSymbol.Locations[0], + targetTypeSymbol.Name + ) + ); + + return; + } + + if ( + handlerSymbol.GetHandleMethod() is not + { + ReturnType: INamedTypeSymbol + { + Arity: 1, + Name: "ValueTask", + ContainingNamespace.IsSystemThreadingTasks: true, + TypeArguments: [ITypeSymbol], + }, + } + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + TargetHandlerMustReturnValue, + cacheSymbol.Locations[0], + targetTypeSymbol.Name + ) + ); + } + } +} + +file static class Extensions +{ + public static ITypeSymbol? GetCacheTargetHandler(this INamedTypeSymbol typeSymbol) + { + foreach (var attribute in typeSymbol.GetAttributes()) + { + if (attribute.AttributeClass is { IsCacheForAttribute: true, TypeArguments: [var targetTypeSymbol] }) + return targetTypeSymbol; + } + + return null; + } +} diff --git a/src/Immediate.Cache.Analyzers/DiagnosticIds.cs b/src/Immediate.Cache.Analyzers/DiagnosticIds.cs new file mode 100644 index 0000000..d9f0184 --- /dev/null +++ b/src/Immediate.Cache.Analyzers/DiagnosticIds.cs @@ -0,0 +1,8 @@ +namespace Immediate.Cache.Analyzers; + +internal static class DiagnosticIds +{ + public const string IC0001CacheMustNotBeNested = "IC0001"; + public const string IC0002TargetMustBeHandler = "IC0002"; + public const string IC0003TargetHandlerMustReturnValue = "IC0003"; +} diff --git a/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs b/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs index 535c5cf..9eb4842 100644 --- a/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs +++ b/tests/Immediate.Cache.FunctionalTests/DelayGetValue.cs @@ -3,7 +3,7 @@ namespace Immediate.Cache.FunctionalTests; [Handler] -public static partial class DelayGetValue +public sealed partial class DelayGetValue { public sealed class Query { @@ -23,7 +23,7 @@ public sealed record Response(int Value, bool ExecutedHandler, Guid RandomValue) private static readonly Lock Lock = new(); - private static async ValueTask HandleAsync( + private async ValueTask HandleAsync( Query query, CancellationToken token ) diff --git a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs index 1bd6632..1478dce 100644 --- a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs +++ b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs @@ -1,17 +1,10 @@ using System.Diagnostics.CodeAnalysis; using Immediate.Cache.Shared; -using Immediate.Handlers.Shared; -using Microsoft.Extensions.Caching.Memory; namespace Immediate.Cache.FunctionalTests; -public sealed class DelayGetValueCache( - IMemoryCache memoryCache, - Owned> ownedHandler -) : ApplicationCache( - memoryCache, - ownedHandler -) +[CacheFor] +public sealed partial class DelayGetValueCache { [SuppressMessage( "Design", diff --git a/tests/Immediate.Cache.FunctionalTests/GetValue.cs b/tests/Immediate.Cache.FunctionalTests/GetValue.cs index 84fdba2..1fefe51 100644 --- a/tests/Immediate.Cache.FunctionalTests/GetValue.cs +++ b/tests/Immediate.Cache.FunctionalTests/GetValue.cs @@ -3,12 +3,12 @@ namespace Immediate.Cache.FunctionalTests; [Handler] -public static partial class GetValue +public sealed partial class GetValue { public sealed record Query(int Value); public sealed record Response(int Value, bool ExecutedHandler); - private static ValueTask HandleAsync( + private ValueTask HandleAsync( Query query, CancellationToken _ ) => ValueTask.FromResult(new Response(query.Value, ExecutedHandler: true)); diff --git a/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs b/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs index f29d0dd..cb0ccc5 100644 --- a/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs +++ b/tests/Immediate.Cache.FunctionalTests/GetValueCache.cs @@ -1,17 +1,10 @@ using System.Diagnostics.CodeAnalysis; using Immediate.Cache.Shared; -using Immediate.Handlers.Shared; -using Microsoft.Extensions.Caching.Memory; namespace Immediate.Cache.FunctionalTests; -public sealed class GetValueCache( - IMemoryCache memoryCache, - Owned> ownedHandler -) : ApplicationCache( - memoryCache, - ownedHandler -) +[CacheFor] +public sealed partial class GetValueCache { [SuppressMessage( "Design", diff --git a/tests/Immediate.Cache.FunctionalTests/Immediate.Cache.FunctionalTests.csproj b/tests/Immediate.Cache.FunctionalTests/Immediate.Cache.FunctionalTests.csproj index 9afcfdb..d3ea781 100644 --- a/tests/Immediate.Cache.FunctionalTests/Immediate.Cache.FunctionalTests.csproj +++ b/tests/Immediate.Cache.FunctionalTests/Immediate.Cache.FunctionalTests.csproj @@ -12,6 +12,8 @@ + + diff --git a/tests/Immediate.Cache.Tests/AnalyzerTests/AnalyzerTestHelpers.cs b/tests/Immediate.Cache.Tests/AnalyzerTests/AnalyzerTestHelpers.cs new file mode 100644 index 0000000..1a37921 --- /dev/null +++ b/tests/Immediate.Cache.Tests/AnalyzerTests/AnalyzerTestHelpers.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Immediate.Cache.Generators; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Immediate.Cache.Tests.AnalyzerTests; + +public static class AnalyzerTestHelpers +{ + public static CSharpAnalyzerTest CreateAnalyzerTest( + [StringSyntax("c#-test")] string inputSource + ) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var csTest = new ImmediateCacheGeneratorAnalyzerTest + { + TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck, + TestState = + { + Sources = { inputSource }, + ReferenceAssemblies = Utility.ReferenceAssemblies, + }, + }; + + csTest.TestState.AdditionalReferences + .AddRange(Utility.GetAdditionalReferences()); + + return csTest; + } + + private sealed class ImmediateCacheGeneratorAnalyzerTest : CSharpAnalyzerTest + where TAnalyzer : DiagnosticAnalyzer, new() + { + protected override IEnumerable GetSourceGenerators() => + [typeof(ImmediateCacheGenerator)]; + } +} diff --git a/tests/Immediate.Cache.Tests/AnalyzerTests/CacheForUsageAnalyzerTests.cs b/tests/Immediate.Cache.Tests/AnalyzerTests/CacheForUsageAnalyzerTests.cs new file mode 100644 index 0000000..c33992c --- /dev/null +++ b/tests/Immediate.Cache.Tests/AnalyzerTests/CacheForUsageAnalyzerTests.cs @@ -0,0 +1,181 @@ +using Immediate.Cache.Analyzers; + +namespace Immediate.Cache.Tests.AnalyzerTests; + +public sealed class CacheForUsageAnalyzerTests +{ + [Fact] + public async Task ValidContainer_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + 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(); + } + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task NestedContainer_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + public class Outer + { + [CacheFor] + public sealed partial class {|IC0001:GetUsersQueryCache|} + { + } + } + + [Handler] + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task NestedNonContainer_DoesNotAlert() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + public class Outer + { + 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; + return new(); + } + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task NonHandlerTarget_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class {|IC0002:GetUsersQueryCache|} + { + } + + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + return new(); + } + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task HandlerTargetNoReturnValue_Alerts() => + await AnalyzerTestHelpers.CreateAnalyzerTest( + """ + using System.Threading; + using System.Threading.Tasks; + using Immediate.Cache.Shared; + using Immediate.Handlers.Shared; + + namespace Dummy; + + [CacheFor] + public sealed partial class {|IC0003:GetUsersQueryCache|} + { + } + + [Handler] + public sealed partial class GetUsersQuery + { + public record Query; + public record Response; + + private async ValueTask HandleAsync( + Query _, + CancellationToken token + ) + { + await Task.CompletedTask; + } + } + """ + ).RunAsync(TestContext.Current.CancellationToken); + +}