diff --git a/.editorconfig b/.editorconfig index aadc998..4e52df2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -279,12 +279,12 @@ dotnet_analyzer_diagnostic.category-Style.severity = warning # 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) +dotnet_diagnostic.CS1573.severity = none # CS1573: Missing XML comment for parameter +dotnet_diagnostic.CS1591.severity = none # CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1712.severity = none # CS1712: Type parameter has no matching typeparam tag in the XML comment (but other type parameters do) # Async -dotnet_diagnostic.CS1998.severity = error # CS1998: Async method lacks 'await' operators and will run synchronously +dotnet_diagnostic.CS1998.severity = none # 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 @@ -298,3 +298,22 @@ dotnet_diagnostic.CA5394.severity = none # CA5394: Random is an i dotnet_diagnostic.CA2000.severity = error # CA2000: Dispose objects before losing scope dotnet_diagnostic.CA1515.severity = none # CA1515: Consider making public types internal + +# Meziantou.Analyzers +MA0053.public_class_should_be_sealed = true +MA0053.exceptions_should_be_sealed = true + +dotnet_diagnostic.MA0004.severity = none +dotnet_diagnostic.MA0048.severity = none +dotnet_diagnostic.MA0051.severity = none +dotnet_diagnostic.MA0053.severity = warning + +[src/Immediate.Cache.Shared/**.cs] + +# XML Documentation +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 diff --git a/Directory.Packages.props b/Directory.Packages.props index d8bc104..44f06f1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,17 @@ + + + + + + + + diff --git a/Immediate.Cache.slnx b/Immediate.Cache.slnx index 6492521..b5a1156 100644 --- a/Immediate.Cache.slnx +++ b/Immediate.Cache.slnx @@ -13,10 +13,12 @@ + + diff --git a/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj new file mode 100644 index 0000000..d385291 --- /dev/null +++ b/src/Immediate.Cache.Analyzers/Immediate.Cache.Analyzers.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + true + true + + + + + + + + + + + + minor + preview.0 + v + + + diff --git a/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs new file mode 100644 index 0000000..1319bb8 --- /dev/null +++ b/src/Immediate.Cache.Analyzers/OwnedDisposableScopeSuppressor.cs @@ -0,0 +1,79 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Immediate.Cache.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class OwnedDisposableScopeSuppressor : DiagnosticSuppressor +{ + public override ImmutableArray SupportedSuppressions => + ImmutableArray.Create([ + new SuppressionDescriptor( + id: "OwnedDisposableScopeSuppression", + suppressedDiagnosticId: "CA2000", + justification: "Suppress disposable not being disposed when used from `Owned.GetScope(out T)`." + ), + ]); + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + var token = context.CancellationToken; + + foreach (var diagnostic in context.ReportedDiagnostics) + { + token.ThrowIfCancellationRequested(); + + var syntaxTree = diagnostic.Location.SourceTree; + + if (syntaxTree + ?.GetRoot(token) + .FindNode(diagnostic.Location.SourceSpan) is not ArgumentSyntax + { + Parent.Parent: InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax + { + Name: IdentifierNameSyntax + { + Identifier.Text: "GetScope", + }, + + Expression: { } expression, + }, + }, + }) + { + continue; + } + + var typeInfo = context.GetSemanticModel(syntaxTree).GetTypeInfo(expression, token); + var symbol = typeInfo.Type ?? typeInfo.ConvertedType; + + if (symbol is not INamedTypeSymbol + { + Arity: 1, + Name: "Owned", + ContainingNamespace: + { + Name: "Cache", + ContainingNamespace: + { + Name: "Immediate", + ContainingNamespace.IsGlobalNamespace: true, + }, + }, + }) + { + continue; + } + + context.ReportSuppression( + Suppression.Create( + SupportedSuppressions[0], + diagnostic + ) + ); + } + } +} diff --git a/src/Immediate.Cache/Immediate.Cache.csproj b/src/Immediate.Cache/Immediate.Cache.csproj index a1e4184..9d77a48 100644 --- a/src/Immediate.Cache/Immediate.Cache.csproj +++ b/src/Immediate.Cache/Immediate.Cache.csproj @@ -10,7 +10,7 @@ A collection of classes that simplify caching responses from Immediate.Handlers handlers. Immediate.Cache Developers - © 2024 Immediate.Cache Developers + © 2024-2026 Immediate.Cache Developers MIT readme.md @@ -29,11 +29,13 @@ + + + + + net8.0 + Exe + <_SkipUpgradeNetAnalyzersNuGetWarning>true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs new file mode 100644 index 0000000..af844fd --- /dev/null +++ b/tests/Immediate.Cache.Tests/SuppressorTests/OwnedDisposableScopeSuppressorTests.cs @@ -0,0 +1,251 @@ +using Immediate.Cache.Analyzers; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.NetCore.Analyzers.Runtime; + +namespace Immediate.Cache.Tests.SuppressorTests; + +public sealed class OwnedDisposableScopeSuppressorTests +{ + public static readonly DiagnosticResult CA2000 = + DiagnosticResult.CompilerWarning("CA2000"); + + [Fact] + public async Task OutArgumentFromGetScopeAsParameterIsSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + public async ValueTask Method(Owned owned) + { + await using var scope = owned.GetScope({|#0:out var service|}); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(true), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task OutArgumentFromGetScopeAsPropertyIsSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + private Owned Owned { get; } = default!; + + public async ValueTask Method() + { + await using var scope = Owned.GetScope({|#0:out var service|}); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(true), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task OutArgumentFromGetScopeAsFieldIsSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + private readonly Owned owned = default!; + + public async ValueTask Method() + { + await using var scope = owned.GetScope({|#0:out var service|}); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(true), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task OutArgumentFromGetScopeAsVariableIsSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + public async ValueTask Method() + { + Owned owned = default!; + await using var scope = owned.GetScope({|#0:out var service|}); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(true), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task OutArgumentFromGeneralMethodIsNotSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + public async ValueTask Method() + { + await using var scope = GetValue({|#0:out var service|}); + } + + private IAsyncDisposable GetValue(out Disposable disposable) + { + disposable = new(); + return new Disposable(); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(false), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task ReturnValueFromGeneralMethodIsNotSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + public void Method() + { + var scope = {|#0:GetValue()|}; + } + + private IDisposable GetValue() + { + return new Disposable(); + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(false), + ]) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task NewValueIsNotSuppressed() => + await SuppressorTestHelpers + .CreateSuppressorTest( + """ + #nullable enable + + using System; + using System.Threading.Tasks; + using Immediate.Cache; + + public sealed class Disposable : IDisposable, IAsyncDisposable + { + public void Dispose() { } + public ValueTask DisposeAsync() => default; + } + + public sealed class Dummy + { + public void Method() + { + var service = {|#0:new Disposable()|}; + } + } + """ + ) + .WithSpecificDiagnostics([CA2000]) + .WithExpectedDiagnosticsResults([ + CA2000.WithLocation(0).WithIsSuppressed(false), + ]) + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs new file mode 100644 index 0000000..f987e7d --- /dev/null +++ b/tests/Immediate.Cache.Tests/SuppressorTests/SuppressorTestHelpers.cs @@ -0,0 +1,138 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Immediate.Cache.Tests.SuppressorTests; + +public static class SuppressorTestHelpers +{ + public sealed class CSharpSuppressorTest : CSharpAnalyzerTest + where TSuppressor : DiagnosticSuppressor, new() + where TVerifier : IVerifier, new() + { + private readonly List _analyzers = []; + + protected override IEnumerable GetDiagnosticAnalyzers() => + base.GetDiagnosticAnalyzers().Concat(_analyzers); + + public CSharpSuppressorTest WithAnalyzer(bool enableDiagnostics = false) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var analyzer = new TAnalyzer(); + _analyzers.Add(analyzer); + + if (enableDiagnostics) + { + var diagnosticOptions = analyzer.SupportedDiagnostics + .ToImmutableDictionary( + descriptor => descriptor.Id, + descriptor => descriptor.DefaultSeverity.ToReportDiagnostic() + ); + + SolutionTransforms.Clear(); + SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions)); + } + + return this; + } + + public CSharpSuppressorTest WithSpecificDiagnostics( + params DiagnosticResult[] diagnostics + ) + { + var diagnosticOptions = diagnostics + .ToImmutableDictionary( + descriptor => descriptor.Id, + descriptor => descriptor.Severity.ToReportDiagnostic() + ); + + SolutionTransforms.Clear(); + SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions)); + return this; + } + + private static Func EnableDiagnostics( + ImmutableDictionary diagnostics + ) => + (solution, id) => + { + var options = solution.GetProject(id)?.CompilationOptions + ?? throw new InvalidOperationException("Compilation options missing."); + + return solution + .WithProjectCompilationOptions( + id, + options + .WithSpecificDiagnosticOptions(diagnostics) + ); + }; + + public CSharpSuppressorTest WithExpectedDiagnosticsResults( + params DiagnosticResult[] diagnostics + ) + { + ExpectedDiagnostics.AddRange(diagnostics); + return this; + } + } + + public static CSharpSuppressorTest CreateSuppressorTest( + [StringSyntax("c#-test")] string inputSource + ) + where TSuppressor : DiagnosticSuppressor, new() + { + var test = new CSharpSuppressorTest + { + TestCode = inputSource, + ReferenceAssemblies = new ReferenceAssemblies( + "net8.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + "8.0.0"), + Path.Combine("ref", "net8.0") + ), + CompilerDiagnostics = CompilerDiagnostics.Warnings, + DisabledDiagnostics = + { + "CS1591", + "CS8767", + }, + TestState = + { + AdditionalReferences = + { + MetadataReference.CreateFromFile("./Immediate.Cache.Shared.dll"), + }, + }, + }; + + return test; + } + + public static CSharpSuppressorTest CreateSuppressorTest( + [StringSyntax("c#-test")] string inputSource + ) + where TSuppressor : DiagnosticSuppressor, new() + where TAnalyzer : DiagnosticAnalyzer, new() + { + return CreateSuppressorTest(inputSource) + .WithAnalyzer(enableDiagnostics: true); + } +} + +file static class DiagnosticSeverityExtensions +{ + public static ReportDiagnostic ToReportDiagnostic(this DiagnosticSeverity severity) + => severity switch + { + DiagnosticSeverity.Hidden => ReportDiagnostic.Hidden, + DiagnosticSeverity.Info => ReportDiagnostic.Info, + DiagnosticSeverity.Warning => ReportDiagnostic.Warn, + DiagnosticSeverity.Error => ReportDiagnostic.Error, + _ => throw new InvalidEnumArgumentException(nameof(severity), (int)severity, typeof(DiagnosticSeverity)), + }; +}