From 56a17815cb4f31b9008bcf6919d4efc70e29e7d1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:35:28 +0000 Subject: [PATCH 1/2] Report unsupported span params on test methods (#7462) --- docs/docs/reference/tunit0077.md | 11 ++++ docs/sidebars.ts | 1 + .../AnalyzerReleases.Unshipped.md | 2 + src/TUnit.Analyzers/DiagnosticIds.cs | 1 + src/TUnit.Analyzers/Resources.resx | 9 +++ src/TUnit.Analyzers/Rules.cs | 3 + .../TestMethodParametersAnalyzer.cs | 17 +++++- .../TestMethodParametersAnalyzerTests.cs | 55 +++++++++++++++++++ 8 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 docs/docs/reference/tunit0077.md diff --git a/docs/docs/reference/tunit0077.md b/docs/docs/reference/tunit0077.md new file mode 100644 index 00000000000..5de86556ba7 --- /dev/null +++ b/docs/docs/reference/tunit0077.md @@ -0,0 +1,11 @@ +# TUnit0077: Span params parameters are not supported on tests + +**Severity:** Error + +TUnit reports this diagnostic when a test method declares a `params ReadOnlySpan` or `params Span` parameter, including generic test methods fed by typed data rows. + +Spans cannot be boxed into the argument array used by reflection invocation. TUnit therefore does not support span params parameters in either execution mode. + +Change the parameter to `params T[]`. For example, replace `params ReadOnlySpan rest` with `params string[] rest`. Array params support packing the trailing elements of a typed data row in both source-generated and reflection modes. + +The rule applies to test methods regardless of their data source. Ordinary helper methods can still use span params. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index d3038d4555a..3819ed708be 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -250,6 +250,7 @@ const sidebars: SidebarsConfig = { 'reference/test-configuration', 'reference/tunit0075', 'reference/tunit0076', + 'reference/tunit0077', { type: 'doc', id: 'guides/best-practices', diff --git a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md index da866f05438..7902a31dc6c 100644 --- a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md @@ -9,6 +9,8 @@ TUnit0074 | Usage | Error | Hook attribute is redundant on an override TUnit0075 | Usage | Warning | Use the hook cancellation token for setup TUnit0076 | Usage | Error | Data source Arguments fit more than one overload equally well +TUnit0077 | Usage | Error | Span params parameters are not supported on tests + ### Removed Rules Rule ID | Category | Severity | Notes diff --git a/src/TUnit.Analyzers/DiagnosticIds.cs b/src/TUnit.Analyzers/DiagnosticIds.cs index d41003fe177..5475e8fad81 100644 --- a/src/TUnit.Analyzers/DiagnosticIds.cs +++ b/src/TUnit.Analyzers/DiagnosticIds.cs @@ -66,5 +66,6 @@ public static class DiagnosticIds public const string MissingPolyfillPackage = "TUnit0073"; public const string RedundantHookAttributeOnOverride = "TUnit0074"; public const string UseHookCancellationToken = "TUnit0075"; + public const string UnsupportedSpanParams = "TUnit0077"; public const string AmbiguousMethodDataSourceOverload = "TUnit0076"; } diff --git a/src/TUnit.Analyzers/Resources.resx b/src/TUnit.Analyzers/Resources.resx index c309b1b5a81..3f19ba8bf40 100644 --- a/src/TUnit.Analyzers/Resources.resx +++ b/src/TUnit.Analyzers/Resources.resx @@ -552,4 +552,13 @@ Data source Arguments fit more than one overload equally well + + Span params parameters are not supported on tests + + + Test parameter '{0}' uses span params, which TUnit cannot invoke consistently in source-generated and reflection modes. Use a params array instead + + + Span<T> and ReadOnlySpan<T> cannot be boxed for reflection invocation. Use params T[] for test parameters so typed data rows can be packed and invoked in both execution modes. + diff --git a/src/TUnit.Analyzers/Rules.cs b/src/TUnit.Analyzers/Rules.cs index a6c93c18e78..30f70e9ef59 100644 --- a/src/TUnit.Analyzers/Rules.cs +++ b/src/TUnit.Analyzers/Rules.cs @@ -6,6 +6,9 @@ public static class Rules { private const string UsageCategory = "Usage"; + public static readonly DiagnosticDescriptor UnsupportedSpanParams = + CreateDescriptor(DiagnosticIds.UnsupportedSpanParams, UsageCategory, DiagnosticSeverity.Error); + public static readonly DiagnosticDescriptor WrongArgumentTypeTestData = CreateDescriptor(DiagnosticIds.WrongArgumentTypeTestData, UsageCategory, DiagnosticSeverity.Error); diff --git a/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs b/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs index bd1cec66893..515eecc9cb2 100644 --- a/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs +++ b/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using TUnit.Analyzers.Extensions; @@ -9,7 +9,7 @@ namespace TUnit.Analyzers; public class TestMethodParametersAnalyzer : ConcurrentDiagnosticAnalyzer { public override ImmutableArray SupportedDiagnostics { get; } = - ImmutableArray.Create(Rules.NoDataSourceProvided); + ImmutableArray.Create(Rules.NoDataSourceProvided, Rules.UnsupportedSpanParams); protected override void InitializeInternal(AnalysisContext context) { @@ -28,6 +28,19 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) return; } + foreach (var parameter in methodSymbol.Parameters) + { + if (parameter.IsParams && parameter.Type is INamedTypeSymbol type + && (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, + context.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")) + || SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, + context.Compilation.GetTypeByMetadataName("System.Span`1")))) + { + context.ReportDiagnostic(Diagnostic.Create(Rules.UnsupportedSpanParams, + parameter.Locations.FirstOrDefault(), parameter.Name)); + } + } + var parameters = methodSymbol.Parameters.WithoutTestMethodCancellationTokens().ToArray(); if (parameters.Length == 0) diff --git a/tests/TUnit.Analyzers.Tests/TestMethodParametersAnalyzerTests.cs b/tests/TUnit.Analyzers.Tests/TestMethodParametersAnalyzerTests.cs index ee5ddd109c6..d26ca53bc25 100644 --- a/tests/TUnit.Analyzers.Tests/TestMethodParametersAnalyzerTests.cs +++ b/tests/TUnit.Analyzers.Tests/TestMethodParametersAnalyzerTests.cs @@ -4,6 +4,61 @@ namespace TUnit.Analyzers.Tests; public class TestMethodParametersAnalyzerTests { + [Test] + [Arguments("ReadOnlySpan")] + [Arguments("Span")] + public async Task Span_Params_Reports_Unsupported_Parameter(string spanType) + { + await Verifier.VerifyAnalyzerAsync( + $$""" + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [Test, Rows] + public void MyTest(T[] values, {|#1:params|} {{spanType}} {|#0:rest|}) { } + } + + public class RowsAttribute : TypedDataSourceAttribute<(int[], string)> + { + public override async IAsyncEnumerable>> GetTypedDataRowsAsync(DataGeneratorMetadata metadata) + { + yield return () => System.Threading.Tasks.Task.FromResult((new[] { 1 }, "hello")); + await System.Threading.Tasks.Task.CompletedTask; + } + } + """, + // The verifier uses Roslyn 4.8, before C# 13 params collections. + Microsoft.CodeAnalysis.Testing.DiagnosticResult.CompilerError("CS0225").WithLocation(1), + Verifier.Diagnostic(Rules.UnsupportedSpanParams).WithLocation(0).WithArguments("rest")); + } + + [Test] + public async Task Array_And_Interface_Params_And_NonTest_Spans_Are_Allowed() + { + await Verifier.VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [Test, Arguments("hello")] + public void ArrayTest(params string[] rest) { } + + [Test, Arguments("hello")] + public void InterfaceTest({|#0:params|} IEnumerable rest) { } + + public void Helper({|#1:params|} ReadOnlySpan rest) { } + } + """, + Microsoft.CodeAnalysis.Testing.DiagnosticResult.CompilerError("CS0225").WithLocation(0), + Microsoft.CodeAnalysis.Testing.DiagnosticResult.CompilerError("CS0225").WithLocation(1)); + } + [Test] public async Task Test_No_Error() { From 56c4f7e56424e3968449e0df71871af4e8ec11ee Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:41:32 +0000 Subject: [PATCH 2/2] Address span params analyzer review feedback (#7462) --- .../AnalyzerReleases.Unshipped.md | 1 - src/TUnit.Analyzers/DiagnosticIds.cs | 2 +- .../TestMethodParametersAnalyzer.cs | 25 +++++++++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md index 7902a31dc6c..10a19f60e5f 100644 --- a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,7 +8,6 @@ TUnit0073 | Usage | Error | Missing polyfill types required by TUnit TUnit0074 | Usage | Error | Hook attribute is redundant on an override TUnit0075 | Usage | Warning | Use the hook cancellation token for setup TUnit0076 | Usage | Error | Data source Arguments fit more than one overload equally well - TUnit0077 | Usage | Error | Span params parameters are not supported on tests ### Removed Rules diff --git a/src/TUnit.Analyzers/DiagnosticIds.cs b/src/TUnit.Analyzers/DiagnosticIds.cs index 5475e8fad81..d59909410be 100644 --- a/src/TUnit.Analyzers/DiagnosticIds.cs +++ b/src/TUnit.Analyzers/DiagnosticIds.cs @@ -66,6 +66,6 @@ public static class DiagnosticIds public const string MissingPolyfillPackage = "TUnit0073"; public const string RedundantHookAttributeOnOverride = "TUnit0074"; public const string UseHookCancellationToken = "TUnit0075"; - public const string UnsupportedSpanParams = "TUnit0077"; public const string AmbiguousMethodDataSourceOverload = "TUnit0076"; + public const string UnsupportedSpanParams = "TUnit0077"; } diff --git a/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs b/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs index 515eecc9cb2..622f3774b23 100644 --- a/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs +++ b/src/TUnit.Analyzers/TestMethodParametersAnalyzer.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using TUnit.Analyzers.Extensions; @@ -13,10 +13,16 @@ public class TestMethodParametersAnalyzer : ConcurrentDiagnosticAnalyzer protected override void InitializeInternal(AnalysisContext context) { - context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.Method); + context.RegisterCompilationStartAction(compilationContext => + { + var readOnlySpanType = compilationContext.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"); + var spanType = compilationContext.Compilation.GetTypeByMetadataName("System.Span`1"); + compilationContext.RegisterSymbolAction( + symbolContext => AnalyzeSymbol(symbolContext, readOnlySpanType, spanType), SymbolKind.Method); + }); } - private void AnalyzeSymbol(SymbolAnalysisContext context) + private static void AnalyzeSymbol(SymbolAnalysisContext context, INamedTypeSymbol? readOnlySpanType, INamedTypeSymbol? spanType) { if (context.Symbol is not IMethodSymbol methodSymbol) { @@ -30,11 +36,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) foreach (var parameter in methodSymbol.Parameters) { - if (parameter.IsParams && parameter.Type is INamedTypeSymbol type - && (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, - context.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")) - || SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, - context.Compilation.GetTypeByMetadataName("System.Span`1")))) + if (parameter.IsParams && IsSpanType(parameter.Type, readOnlySpanType, spanType)) { context.ReportDiagnostic(Diagnostic.Create(Rules.UnsupportedSpanParams, parameter.Locations.FirstOrDefault(), parameter.Name)); @@ -54,4 +56,11 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) parameters[0].Locations.FirstOrDefault() ?? methodSymbol.Locations.FirstOrDefault())); } } + + private static bool IsSpanType(ITypeSymbol type, INamedTypeSymbol? readOnlySpanType, INamedTypeSymbol? spanType) + { + return type is INamedTypeSymbol namedType + && (SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, readOnlySpanType) + || SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, spanType)); + } }