diff --git a/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs b/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs index f50ffebce1..44a76d53c9 100644 --- a/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs @@ -11,7 +11,7 @@ await Verifier.VerifyAnalyzerAsync( """ using TUnit.Core; using System.Threading.Tasks; - + public class TestClass { [Test] @@ -35,7 +35,7 @@ await Verifier.VerifyAnalyzerAsync( using TUnit.Core; using System.Threading; using System.Threading.Tasks; - + public class TestClass { [Test] @@ -56,7 +56,7 @@ await Verifier.VerifyAnalyzerAsync( """ using TUnit.Core; using System.Threading.Tasks; - + [Timeout(30_000)] public class TestClass { @@ -80,7 +80,7 @@ await Verifier.VerifyAnalyzerAsync( using TUnit.Core; using System.Threading; using System.Threading.Tasks; - + [Timeout(30_000)] public class TestClass { @@ -103,12 +103,12 @@ await Verifier.VerifyAnalyzerAsync( using TUnit.Core; using System.Threading; using System.Threading.Tasks; - + [Timeout(30_000)] public class TestClass { private static HttpClient GetHttpClient() => new HttpClient(); - + [Test] public async Task TestMethod(CancellationToken cancellationToken) { @@ -128,7 +128,7 @@ await Verifier.VerifyAnalyzerAsync( using TUnit.Core; using System.Threading; using System.Threading.Tasks; - + [Timeout(30_000)] public class TestClass { @@ -136,7 +136,7 @@ internal void HelperMethod() { // Some helper logic } - + [Test] public async Task TestMethod(CancellationToken cancellationToken) { @@ -156,7 +156,7 @@ await Verifier.VerifyAnalyzerAsync( using TUnit.Core; using System.Threading; using System.Threading.Tasks; - + [Timeout(30_000)] public class TestClass { @@ -164,7 +164,7 @@ private async Task DoSomethingAsync() { await Task.Delay(100); } - + [Test] public async Task TestMethod(CancellationToken cancellationToken) { @@ -183,7 +183,7 @@ await Verifier.VerifyAnalyzerAsync( """ using TUnit.Core; using System.Threading.Tasks; - + public class TestClass { // This shouldn't happen in practice as Timeout should only be on test/hook methods, @@ -193,7 +193,7 @@ private async Task HelperMethodWithTimeout() { await Task.Delay(100); } - + [Test] public async Task TestMethod() { @@ -203,4 +203,102 @@ public async Task TestMethod() """ ); } + + [Test] + public async Task Test_Method_With_CancellationToken_Not_Last_Shows_Wrong_Order_Error() + { + await Verifier.VerifyAnalyzerAsync( + """ + using TUnit.Core; + using System.Threading; + using System.Threading.Tasks; + + public class TestClass + { + [Test] + [Arguments(1)] + [Timeout(30_000)] + public async Task {|#0:TestMethod|}(CancellationToken cancellationToken, int value) + { + await Task.Delay(100, cancellationToken); + } + } + """, + Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter) + .WithLocation(0) + ); + } + + [Test] + public async Task Test_Method_With_Data_And_CancellationToken_Last_Shows_No_Error() + { + await Verifier.VerifyAnalyzerAsync( + """ + using TUnit.Core; + using System.Threading; + using System.Threading.Tasks; + + public class TestClass + { + [Test] + [Arguments(1)] + [Timeout(30_000)] + public async Task TestMethod(int value, CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + } + } + """ + ); + } + + [Test] + public async Task Test_Method_With_CancellationToken_First_Of_Multiple_Shows_Wrong_Order_Error() + { + await Verifier.VerifyAnalyzerAsync( + """ + using TUnit.Core; + using System.Threading; + using System.Threading.Tasks; + + public class TestClass + { + [Test] + [Arguments(1, "hello")] + [Timeout(30_000)] + public async Task {|#0:TestMethod|}(CancellationToken cancellationToken, int value, string text) + { + await Task.Delay(100, cancellationToken); + } + } + """, + Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter) + .WithLocation(0) + ); + } + + [Test] + public async Task Test_Method_With_CancellationToken_In_Middle_Shows_Wrong_Order_Error() + { + await Verifier.VerifyAnalyzerAsync( + """ + using TUnit.Core; + using System.Threading; + using System.Threading.Tasks; + + public class TestClass + { + [Test] + [Arguments(1, "hello")] + [Timeout(30_000)] + public async Task {|#0:TestMethod|}(int value, CancellationToken cancellationToken, string text) + { + await Task.Delay(100, cancellationToken); + } + } + """, + Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter) + .WithLocation(0) + ); + } } \ No newline at end of file diff --git a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md index b02d473125..f64b591235 100644 --- a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md @@ -3,6 +3,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- TUnit0061 | Usage | Error | ClassDataSource type requires parameterless constructor +TUnit0062 | Usage | Warning | CancellationToken must be the last parameter ### Removed Rules diff --git a/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/Resources.resx index fff258bca8..7ae4425c17 100644 --- a/TUnit.Analyzers/Resources.resx +++ b/TUnit.Analyzers/Resources.resx @@ -498,6 +498,15 @@ Conflicting data source attributes + + CancellationToken parameter must be the last parameter in the method signature. Move it to the end of the parameter list. + + + CancellationToken must be the last parameter + + + CancellationToken must be the last parameter + Generic types and methods may not be AOT-compatible when using dynamic type creation. Consider using concrete types or ensure all generic combinations are known at compile time. diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs index d006c3d7a5..489ede5331 100644 --- a/TUnit.Analyzers/Rules.cs +++ b/TUnit.Analyzers/Rules.cs @@ -42,6 +42,9 @@ public static class Rules public static readonly DiagnosticDescriptor MissingTimeoutCancellationTokenAttributes = CreateDescriptor("TUnit0015", UsageCategory, DiagnosticSeverity.Warning); + public static readonly DiagnosticDescriptor CancellationTokenMustBeLastParameter = + CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Warning); + public static readonly DiagnosticDescriptor MethodMustNotBeStatic = CreateDescriptor("TUnit0016", UsageCategory, DiagnosticSeverity.Error); diff --git a/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs b/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs index a23ee1e71b..78fc557e61 100644 --- a/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs +++ b/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs @@ -9,7 +9,9 @@ namespace TUnit.Analyzers; public class TimeoutCancellationTokenAnalyzer : ConcurrentDiagnosticAnalyzer { public override ImmutableArray SupportedDiagnostics { get; } = - ImmutableArray.Create(Rules.MissingTimeoutCancellationTokenAttributes); + ImmutableArray.Create( + Rules.MissingTimeoutCancellationTokenAttributes, + Rules.CancellationTokenMustBeLastParameter); protected override void InitializeInternal(AnalysisContext context) { @@ -23,7 +25,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) return; } - if (!methodSymbol.IsTestMethod(context.Compilation) && + if (!methodSymbol.IsTestMethod(context.Compilation) && !methodSymbol.IsHookMethod(context.Compilation, out _, out _, out _)) { return; @@ -51,15 +53,33 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) return; } - var lastParameter = parameters.Last(); + var cancellationTokenType = context.Compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!); - if (!SymbolEqualityComparer.Default.Equals(lastParameter.Type, - context.Compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!))) + var cancellationTokenIndex = -1; + for (var i = 0; i < parameters.Length; i++) { + if (SymbolEqualityComparer.Default.Equals(parameters[i].Type, cancellationTokenType)) + { + cancellationTokenIndex = i; + break; + } + } + + if (cancellationTokenIndex == -1) + { + // CancellationToken is not present at all context.ReportDiagnostic( Diagnostic.Create(Rules.MissingTimeoutCancellationTokenAttributes, context.Symbol.Locations.FirstOrDefault()) ); } + else if (cancellationTokenIndex != parameters.Length - 1) + { + // CancellationToken exists but is not the last parameter + context.ReportDiagnostic( + Diagnostic.Create(Rules.CancellationTokenMustBeLastParameter, + context.Symbol.Locations.FirstOrDefault()) + ); + } } }