From 50117fb8fe4112f7f7a0aa7c2054975f8da7b72b Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 00:19:41 -0700 Subject: [PATCH 1/3] Add INTL0304 Task.Delay(0) analyzer Implement analyzer and code fix for Task.Delay(0), including cancellation-token semantics and runtime-source documentation links. Add release metadata, docs updates, and expanded tests for await, CancellationToken.None, and safe code-fix applicability. --- Directory.Packages.props | 50 +-- .../TaskDelayZero.cs | 181 ++++++++++ .../TaskDelayZeroTests.cs | 323 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 5 + .../Analyzers/TaskDelayZero.cs | 85 +++++ docs/analyzers/03XX.Performance.md | 19 +- docs/analyzers/index.md | 3 +- 7 files changed, 639 insertions(+), 27 deletions(-) create mode 100644 IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs create mode 100644 IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs create mode 100644 IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3950a8f6..0319604a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,25 +1,25 @@ - - - true - false - - - - - - - - - - - - - - - - - - - - - + + + true + false + + + + + + + + + + + + + + + + + + + + + diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs new file mode 100644 index 00000000..8cd66985 --- /dev/null +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs @@ -0,0 +1,181 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace IntelliTect.Analyzer.CodeFixes +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(TaskDelayZero))] + [Shared] + public class TaskDelayZero : CodeFixProvider + { + private const string Title = "Use Task.CompletedTask"; + + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(Analyzers.TaskDelayZero.DiagnosticId); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + Diagnostic diagnostic = context.Diagnostics.First(); + TextSpan diagnosticSpan = diagnostic.Location.SourceSpan; + + InvocationExpressionSyntax? invocation = root.FindToken(diagnosticSpan.Start) + .Parent?.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + if (invocation is null) + { + return; + } + + SemanticModel? semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + if (semanticModel.GetOperation(invocation, context.CancellationToken) is not IInvocationOperation invocationOperation) + { + return; + } + + ExpressionSyntax? replacement = CreateReplacementExpression(invocationOperation); + if (replacement is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: Title, + createChangedDocument: c => ReplaceInvocationAsync(context.Document, invocation, replacement, c), + equivalenceKey: Title), + diagnostic); + } + + private static ExpressionSyntax? CreateReplacementExpression(IInvocationOperation invocation) + { + if (!IsTaskDelayWithIntMilliseconds(invocation.TargetMethod)) + { + return null; + } + + IArgumentOperation? millisecondsDelayArgument = invocation.Arguments + .FirstOrDefault(a => a.Parameter?.Name == "millisecondsDelay"); + if (millisecondsDelayArgument?.Value.ConstantValue is not { HasValue: true, Value: int millisecondsDelay } + || millisecondsDelay != 0) + { + return null; + } + + IArgumentOperation? cancellationTokenArgument = invocation.Arguments + .FirstOrDefault(a => a.Parameter?.Name == "cancellationToken"); + if (cancellationTokenArgument is null) + { + return SyntaxFactory.ParseExpression("global::System.Threading.Tasks.Task.CompletedTask"); + } + + if (cancellationTokenArgument.Value.Syntax is not ExpressionSyntax cancellationTokenExpression) + { + return null; + } + + if (!IsSideEffectFree(cancellationTokenArgument.Value)) + { + return null; + } + + string tokenExpressionText = NormalizeCancellationTokenExpression(cancellationTokenExpression); + + // Runtime behavior reference: + // https://github.com/dotnet/runtime/blob/1acc89c305165239a5a824567a3176b6b3342790/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L5907-L5911 + // Task.Delay(0, token) maps to: + // token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask + return SyntaxFactory.ParseExpression( + $"({tokenExpressionText}.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled({tokenExpressionText}) : global::System.Threading.Tasks.Task.CompletedTask)"); + } + + private static string NormalizeCancellationTokenExpression(ExpressionSyntax cancellationTokenExpression) + { + return cancellationTokenExpression.Kind() switch + { + SyntaxKind.DefaultLiteralExpression => "default(global::System.Threading.CancellationToken)", + SyntaxKind.DefaultExpression => cancellationTokenExpression.WithoutTrivia().ToString() == "default" + ? "default(global::System.Threading.CancellationToken)" + : cancellationTokenExpression.WithoutTrivia().ToString(), + _ => cancellationTokenExpression.WithoutTrivia().ToString() + }; + } + + private static bool IsSideEffectFree(IOperation operation) + { + return operation switch + { + ILocalReferenceOperation => true, + IParameterReferenceOperation => true, + IDefaultValueOperation => true, + IConversionOperation conversion => IsSideEffectFree(conversion.Operand), + IParenthesizedOperation parenthesized => IsSideEffectFree(parenthesized.Operand), + IPropertyReferenceOperation propertyReference + when propertyReference.Instance is null + && propertyReference.Property.Name == "None" + && propertyReference.Property.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + == "global::System.Threading.CancellationToken" => true, + _ => false + }; + } + + private static bool IsTaskDelayWithIntMilliseconds(IMethodSymbol methodSymbol) + { + if (methodSymbol.Name != "Delay") + { + return false; + } + + if (methodSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + != "global::System.Threading.Tasks.Task") + { + return false; + } + + return methodSymbol.Parameters.Length >= 1 + && methodSymbol.Parameters[0].Name == "millisecondsDelay" + && methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_Int32; + } + + private static async Task ReplaceInvocationAsync( + Document document, + InvocationExpressionSyntax invocation, + ExpressionSyntax replacement, + CancellationToken cancellationToken) + { + SyntaxNode oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) + ?? throw new System.InvalidOperationException("Could not get syntax root"); + + ExpressionSyntax replacementExpression = replacement + .WithTriviaFrom(invocation) + .WithAdditionalAnnotations(Formatter.Annotation); + + SyntaxNode newRoot = oldRoot.ReplaceNode(invocation, replacementExpression); + return document.WithSyntaxRoot(newRoot); + } + } +} diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs new file mode 100644 index 00000000..e5487c99 --- /dev/null +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs @@ -0,0 +1,323 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TestHelper; + +namespace IntelliTect.Analyzer.Tests +{ + [TestClass] + public class TaskDelayZeroTests : CodeFixVerifier + { + [TestMethod] + public void TaskDelayZero_ProducesInfoMessage() + { + string source = """ + using System.Threading.Tasks; + + class C + { + void M() + { + Task.Delay(0); + } + } + """; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0304", + Severity = DiagnosticSeverity.Info, + Message = "Replace Task.Delay(0) with Task.CompletedTask", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 7, 9) + ] + }); + } + + [TestMethod] + public void TaskDelayZeroWithToken_ProducesInfoMessage() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + void M(CancellationToken token) + { + Task.Delay(0, token); + } + } + """; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0304", + Severity = DiagnosticSeverity.Info, + Message = "Replace Task.Delay(0) with Task.CompletedTask", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 8, 9) + ] + }); + } + + [TestMethod] + public void TaskDelayNonZero_ProducesNoDiagnostic() + { + string source = """ + using System.Threading.Tasks; + + class C + { + void M() + { + Task.Delay(1); + } + } + """; + + VerifyCSharpDiagnostic(source); + } + + [TestMethod] + public async Task TaskDelayZero_CodeFix_UsesCompletedTask() + { + string source = """ + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0); + } + } + """; + + string fixedSource = """ + using System.Threading.Tasks; + + class C + { + Task M() + { + return global::System.Threading.Tasks.Task.CompletedTask; + } + } + """; + + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task TaskDelayZeroWithToken_CodeFix_PreservesCancellationBehavior() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M(CancellationToken token) + { + return Task.Delay(0, token); + } + } + """; + + string fixedSource = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M(CancellationToken token) + { + return (token.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(token) : global::System.Threading.Tasks.Task.CompletedTask); + } + } + """; + + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task TaskDelayZeroWithDefaultToken_CodeFix_UsesTypedDefault() + { + string source = """ + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0, default); + } + } + """; + + string fixedSource = """ + using System.Threading.Tasks; + + class C + { + Task M() + { + return (default(global::System.Threading.CancellationToken).IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(default(global::System.Threading.CancellationToken)) : global::System.Threading.Tasks.Task.CompletedTask); + } + } + """; + + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task TaskDelayZeroWithSideEffectingTokenExpression_NoCodeFix() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0, GetToken()); + } + + CancellationToken GetToken() => default; + } + """; + + await VerifyCSharpFix(source, source); + } + + [TestMethod] + public async Task TaskDelayZeroWithPropertyTokenExpression_NoCodeFix() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0, Token); + } + + CancellationToken Token => default; + } + """; + + await VerifyCSharpFix(source, source); + } + + [TestMethod] + public async Task TaskDelayZeroWithCancellationTokenNone_CodeFix_PreservesCancellationBehavior() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0, CancellationToken.None); + } + } + """; + + string fixedSource = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M() + { + return (CancellationToken.None.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(CancellationToken.None) : global::System.Threading.Tasks.Task.CompletedTask); + } + } + """; + + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task AwaitTaskDelayZero_CodeFix_UsesCompletedTask() + { + string source = """ + using System.Threading.Tasks; + + class C + { + async Task M() + { + await Task.Delay(0); + } + } + """; + + string fixedSource = """ + using System.Threading.Tasks; + + class C + { + async Task M() + { + await global::System.Threading.Tasks.Task.CompletedTask; + } + } + """; + + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public void TaskDelayZeroNamedArguments_ProducesInfoMessage() + { + string source = """ + using System.Threading; + using System.Threading.Tasks; + + class C + { + void M(CancellationToken token) + { + Task.Delay(cancellationToken: token, millisecondsDelay: 0); + } + } + """; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0304", + Severity = DiagnosticSeverity.Info, + Message = "Replace Task.Delay(0) with Task.CompletedTask", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 8, 9) + ] + }); + } + + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() + { + return new Analyzers.TaskDelayZero(); + } + + protected override CodeFixProvider GetCSharpCodeFixProvider() + { + return new CodeFixes.TaskDelayZero(); + } + } +} diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/AnalyzerReleases.Unshipped.md b/IntelliTect.Analyzer/IntelliTect.Analyzer/AnalyzerReleases.Unshipped.md index 6f6766da..4372e6ed 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/AnalyzerReleases.Unshipped.md +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/AnalyzerReleases.Unshipped.md @@ -1,3 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +INTL0304 | Performance | Info | TaskDelayZero diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs new file mode 100644 index 00000000..d6988274 --- /dev/null +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace IntelliTect.Analyzer.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class TaskDelayZero : DiagnosticAnalyzer + { + public const string DiagnosticId = "INTL0304"; + private const string Title = "Use Task.CompletedTask for zero-delay task"; + private const string MessageFormat = "Replace Task.Delay(0) with Task.CompletedTask"; + private const string Description = + "Task.Delay with a zero millisecond delay can be replaced with Task.CompletedTask."; + private const string Category = "Performance"; + private static readonly string _HelpLinkUri = DiagnosticUrlBuilder.GetUrl(Title, DiagnosticId); + + private static readonly DiagnosticDescriptor _Rule = new( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: Description, + helpLinkUri: _HelpLinkUri); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(_Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context) + { + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + if (!IsTaskDelayWithIntMilliseconds(invocation.TargetMethod)) + { + return; + } + + IArgumentOperation? millisecondsDelayArgument = invocation.Arguments + .FirstOrDefault(a => string.Equals(a.Parameter?.Name, "millisecondsDelay", StringComparison.Ordinal)); + + if (millisecondsDelayArgument?.Value.ConstantValue is { HasValue: true, Value: int millisecondsDelay } + && millisecondsDelay == 0) + { + context.ReportDiagnostic(Diagnostic.Create(_Rule, invocation.Syntax.GetLocation())); + } + } + + internal static bool IsTaskDelayWithIntMilliseconds(IMethodSymbol methodSymbol) + { + if (!string.Equals(methodSymbol.Name, "Delay", StringComparison.Ordinal)) + { + return false; + } + + if (methodSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + != "global::System.Threading.Tasks.Task") + { + return false; + } + + return methodSymbol.Parameters.Length >= 1 + && string.Equals(methodSymbol.Parameters[0].Name, "millisecondsDelay", StringComparison.Ordinal) + && methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_Int32; + } + } +} diff --git a/docs/analyzers/03XX.Performance.md b/docs/analyzers/03XX.Performance.md index 73b265b9..4bde39e4 100644 --- a/docs/analyzers/03XX.Performance.md +++ b/docs/analyzers/03XX.Performance.md @@ -38,4 +38,21 @@ All local variables should be accessed, or replaced with [discards](https://docs ### When to suppress -Do not suppress a warning from this rule. \ No newline at end of file +Do not suppress a warning from this rule. + +## INTL0304 +### `Task.Delay(0)` should use `Task.CompletedTask`. + +`Task.Delay(0)` introduces unnecessary overhead because the delay is already complete. Prefer +`Task.CompletedTask` for the zero-delay case. + +For `Task.Delay(0, cancellationToken)`, preserve behavior by accounting for pre-canceled tokens: + +`cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : Task.CompletedTask` + +This behavior mirrors the runtime implementation in `Task.Delay(uint, TimeProvider, CancellationToken)`: + + +### When to suppress + +Do not suppress a warning from this rule. diff --git a/docs/analyzers/index.md b/docs/analyzers/index.md index 7715562b..b22faa09 100644 --- a/docs/analyzers/index.md +++ b/docs/analyzers/index.md @@ -11,4 +11,5 @@ ## Performance INTL03XX - [INTL0301 Favor EnumerateFiles]({{ site.baseurl }}{% link analyzers/03XX.Performance.md %}#intl0301) -- [INTL0302 Favor EnumerateDirectories]({{ site.baseurl }}{% link analyzers/03XX.Performance.md %}#intl0302) \ No newline at end of file +- [INTL0302 Favor EnumerateDirectories]({{ site.baseurl }}{% link analyzers/03XX.Performance.md %}#intl0302) +- [INTL0304 Task.Delay(0) can be Task.CompletedTask]({{ site.baseurl }}{% link analyzers/03XX.Performance.md %}#intl0304) From b3c18a207ad7f8d66f199aa43195e61bcf7e3357 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 01:17:22 -0700 Subject: [PATCH 2/3] Eliminate DRY violations in INTL0304 implementation - Remove duplicate IsTaskDelayWithIntMilliseconds from CodeFixes/TaskDelayZero.cs; call the public static method on Analyzers.TaskDelayZero instead - Make Analyzers.TaskDelayZero.IsTaskDelayWithIntMilliseconds public so the CodeFixes assembly can reference it without InternalsVisibleTo - Extract DiagnosticId and message into constants in TaskDelayZeroTests to avoid repeated string literals --- .../TaskDelayZero.cs | 20 +------------------ .../TaskDelayZeroTests.cs | 14 +++++++------ .../Analyzers/TaskDelayZero.cs | 2 +- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs index 8cd66985..1a4f3c96 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs @@ -73,7 +73,7 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) private static ExpressionSyntax? CreateReplacementExpression(IInvocationOperation invocation) { - if (!IsTaskDelayWithIntMilliseconds(invocation.TargetMethod)) + if (!IntelliTect.Analyzer.Analyzers.TaskDelayZero.IsTaskDelayWithIntMilliseconds(invocation.TargetMethod)) { return null; } @@ -143,24 +143,6 @@ when propertyReference.Instance is null }; } - private static bool IsTaskDelayWithIntMilliseconds(IMethodSymbol methodSymbol) - { - if (methodSymbol.Name != "Delay") - { - return false; - } - - if (methodSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - != "global::System.Threading.Tasks.Task") - { - return false; - } - - return methodSymbol.Parameters.Length >= 1 - && methodSymbol.Parameters[0].Name == "millisecondsDelay" - && methodSymbol.Parameters[0].Type.SpecialType == SpecialType.System_Int32; - } - private static async Task ReplaceInvocationAsync( Document document, InvocationExpressionSyntax invocation, diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs index e5487c99..ebc9c93a 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs @@ -10,6 +10,8 @@ namespace IntelliTect.Analyzer.Tests [TestClass] public class TaskDelayZeroTests : CodeFixVerifier { + private const string DiagnosticId = Analyzers.TaskDelayZero.DiagnosticId; + private const string DiagnosticMessage = "Replace Task.Delay(0) with Task.CompletedTask"; [TestMethod] public void TaskDelayZero_ProducesInfoMessage() { @@ -28,9 +30,9 @@ void M() VerifyCSharpDiagnostic(source, new DiagnosticResult { - Id = "INTL0304", + Id = DiagnosticId, Severity = DiagnosticSeverity.Info, - Message = "Replace Task.Delay(0) with Task.CompletedTask", + Message = DiagnosticMessage, Locations = [ new DiagnosticResultLocation("Test0.cs", 7, 9) @@ -57,9 +59,9 @@ void M(CancellationToken token) VerifyCSharpDiagnostic(source, new DiagnosticResult { - Id = "INTL0304", + Id = DiagnosticId, Severity = DiagnosticSeverity.Info, - Message = "Replace Task.Delay(0) with Task.CompletedTask", + Message = DiagnosticMessage, Locations = [ new DiagnosticResultLocation("Test0.cs", 8, 9) @@ -300,9 +302,9 @@ void M(CancellationToken token) VerifyCSharpDiagnostic(source, new DiagnosticResult { - Id = "INTL0304", + Id = DiagnosticId, Severity = DiagnosticSeverity.Info, - Message = "Replace Task.Delay(0) with Task.CompletedTask", + Message = DiagnosticMessage, Locations = [ new DiagnosticResultLocation("Test0.cs", 8, 9) diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs index d6988274..446fad34 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/TaskDelayZero.cs @@ -64,7 +64,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext context) } } - internal static bool IsTaskDelayWithIntMilliseconds(IMethodSymbol methodSymbol) + public static bool IsTaskDelayWithIntMilliseconds(IMethodSymbol methodSymbol) { if (!string.Equals(methodSymbol.Name, "Delay", StringComparison.Ordinal)) { From ed2cb5db191743d38726e3a167b5e28c941ab4c5 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 17:10:46 -0700 Subject: [PATCH 3/3] Address PR review feedback for INTL0304 code fix - Add Simplifier annotations so code-fix output simplifies Task symbols - Build replacement expressions with syntax nodes instead of raw strings - Skip code-fix rewrites when Task.Delay overload includes timeProvider - Add regression tests for TimeProvider overload no-fix behavior - Update fixed-code expectations for simplified output --- .../TaskDelayZero.cs | 43 ++++++++++++++-- .../TaskDelayZeroTests.cs | 49 +++++++++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs index 1a4f3c96..59ae5e70 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/TaskDelayZero.cs @@ -10,6 +10,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Simplification; using Microsoft.CodeAnalysis.Text; namespace IntelliTect.Analyzer.CodeFixes @@ -86,11 +87,18 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) return null; } + // Task.Delay overloads that include a TimeProvider cannot be safely rewritten to + // Task.CompletedTask/Task.FromCanceled without changing observable behavior. + if (invocation.Arguments.Any(a => a.Parameter?.Name == "timeProvider")) + { + return null; + } + IArgumentOperation? cancellationTokenArgument = invocation.Arguments .FirstOrDefault(a => a.Parameter?.Name == "cancellationToken"); if (cancellationTokenArgument is null) { - return SyntaxFactory.ParseExpression("global::System.Threading.Tasks.Task.CompletedTask"); + return CreateTaskCompletedTaskExpression(); } if (cancellationTokenArgument.Value.Syntax is not ExpressionSyntax cancellationTokenExpression) @@ -104,13 +112,40 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) } string tokenExpressionText = NormalizeCancellationTokenExpression(cancellationTokenExpression); + ExpressionSyntax normalizedTokenExpression = SyntaxFactory.ParseExpression(tokenExpressionText) + .WithAdditionalAnnotations(Simplifier.Annotation); // Runtime behavior reference: // https://github.com/dotnet/runtime/blob/1acc89c305165239a5a824567a3176b6b3342790/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L5907-L5911 // Task.Delay(0, token) maps to: // token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask - return SyntaxFactory.ParseExpression( - $"({tokenExpressionText}.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled({tokenExpressionText}) : global::System.Threading.Tasks.Task.CompletedTask)"); + return SyntaxFactory.ConditionalExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + normalizedTokenExpression, + SyntaxFactory.IdentifierName("IsCancellationRequested")), + CreateTaskFromCanceledExpression(normalizedTokenExpression), + CreateTaskCompletedTaskExpression()); + } + + private static ExpressionSyntax CreateTaskCompletedTaskExpression() + { + return SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ParseName("global::System.Threading.Tasks.Task").WithAdditionalAnnotations(Simplifier.Annotation), + SyntaxFactory.IdentifierName("CompletedTask")); + } + + private static InvocationExpressionSyntax CreateTaskFromCanceledExpression(ExpressionSyntax cancellationTokenExpression) + { + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ParseName("global::System.Threading.Tasks.Task").WithAdditionalAnnotations(Simplifier.Annotation), + SyntaxFactory.IdentifierName("FromCanceled")), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(cancellationTokenExpression)))); } private static string NormalizeCancellationTokenExpression(ExpressionSyntax cancellationTokenExpression) @@ -154,7 +189,7 @@ private static async Task ReplaceInvocationAsync( ExpressionSyntax replacementExpression = replacement .WithTriviaFrom(invocation) - .WithAdditionalAnnotations(Formatter.Annotation); + .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation); SyntaxNode newRoot = oldRoot.ReplaceNode(invocation, replacementExpression); return document.WithSyntaxRoot(newRoot); diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs index ebc9c93a..af88dbef 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/TaskDelayZeroTests.cs @@ -109,7 +109,7 @@ class C { Task M() { - return global::System.Threading.Tasks.Task.CompletedTask; + return Task.CompletedTask; } } """; @@ -141,7 +141,7 @@ class C { Task M(CancellationToken token) { - return (token.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(token) : global::System.Threading.Tasks.Task.CompletedTask); + return token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask; } } """; @@ -171,7 +171,7 @@ class C { Task M() { - return (default(global::System.Threading.CancellationToken).IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(default(global::System.Threading.CancellationToken)) : global::System.Threading.Tasks.Task.CompletedTask); + return default(global::System.Threading.CancellationToken).IsCancellationRequested ? Task.FromCanceled(default) : Task.CompletedTask; } } """; @@ -245,7 +245,7 @@ class C { Task M() { - return (CancellationToken.None.IsCancellationRequested ? global::System.Threading.Tasks.Task.FromCanceled(CancellationToken.None) : global::System.Threading.Tasks.Task.CompletedTask); + return CancellationToken.None.IsCancellationRequested ? Task.FromCanceled(CancellationToken.None) : Task.CompletedTask; } } """; @@ -253,6 +253,45 @@ Task M() await VerifyCSharpFix(source, fixedSource); } + [TestMethod] + public async Task TaskDelayZeroWithTimeProvider_NoCodeFix() + { + string source = """ + using System; + using System.Threading.Tasks; + + class C + { + Task M() + { + return Task.Delay(0, TimeProvider.System); + } + } + """; + + await VerifyCSharpFix(source, source); + } + + [TestMethod] + public async Task TaskDelayZeroWithTimeProviderAndToken_NoCodeFix() + { + string source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + + class C + { + Task M(CancellationToken token) + { + return Task.Delay(0, TimeProvider.System, token); + } + } + """; + + await VerifyCSharpFix(source, source); + } + [TestMethod] public async Task AwaitTaskDelayZero_CodeFix_UsesCompletedTask() { @@ -275,7 +314,7 @@ class C { async Task M() { - await global::System.Threading.Tasks.Task.CompletedTask; + await Task.CompletedTask; } } """;