Skip to content
Merged
6 changes: 6 additions & 0 deletions src/Analyzers/KnownTypeSymbols.Net.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed partial class KnownTypeSymbols
INamedTypeSymbol? cancellationToken;
INamedTypeSymbol? environment;
INamedTypeSymbol? httpClient;
INamedTypeSymbol? timeProvider;
INamedTypeSymbol? iLogger;

/// <summary>
Expand Down Expand Up @@ -81,4 +82,9 @@ public sealed partial class KnownTypeSymbols
/// Gets an ILogger type symbol.
/// </summary>
public INamedTypeSymbol? ILogger => this.GetOrResolveFullyQualifiedType("Microsoft.Extensions.Logging.ILogger", ref this.iLogger);

/// <summary>
/// Gets a TimeProvider type symbol.
/// </summary>
public INamedTypeSymbol? TimeProvider => this.GetOrResolveFullyQualifiedType("System.TimeProvider", ref this.timeProvider);
}
33 changes: 31 additions & 2 deletions src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace Microsoft.DurableTask.Analyzers.Orchestration;

/// <summary>
/// Analyzer that reports a warning when a non-deterministic DateTime or DateTimeOffset property is used in an orchestration method.
/// Analyzer that reports a warning when a non-deterministic DateTime, DateTimeOffset, or TimeProvider method is used in an orchestration method.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer<DateTimeOrchestrationVisitor>
Expand All @@ -36,18 +36,20 @@ public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer<DateTi
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

/// <summary>
/// Visitor that inspects the method body for DateTime and DateTimeOffset properties.
/// Visitor that inspects the method body for DateTime and DateTimeOffset properties, and TimeProvider method invocations.
/// </summary>
public sealed class DateTimeOrchestrationVisitor : MethodProbeOrchestrationVisitor
{
INamedTypeSymbol systemDateTimeSymbol = null!;
INamedTypeSymbol? systemDateTimeOffsetSymbol;
INamedTypeSymbol? systemTimeProviderSymbol;

/// <inheritdoc/>
public override bool Initialize()
{
this.systemDateTimeSymbol = this.Compilation.GetSpecialType(SpecialType.System_DateTime);
this.systemDateTimeOffsetSymbol = this.Compilation.GetTypeByMetadataName("System.DateTimeOffset");
this.systemTimeProviderSymbol = this.Compilation.GetTypeByMetadataName("System.TimeProvider");
return true;
}

Expand Down Expand Up @@ -86,6 +88,33 @@ protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode meth
reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, methodSymbol.Name, property.ToString(), orchestrationName));
}
}

// Check for TimeProvider method invocations
if (this.systemTimeProviderSymbol is not null)
{
foreach (IInvocationOperation operation in methodOperation.Descendants().OfType<IInvocationOperation>())
{
IMethodSymbol invokedMethod = operation.TargetMethod;

// Check if the method is called on TimeProvider type
bool isTimeProvider = invokedMethod.ContainingType.Equals(this.systemTimeProviderSymbol, SymbolEqualityComparer.Default);

if (!isTimeProvider)
{
continue;
}

// Check for non-deterministic TimeProvider methods: GetUtcNow, GetLocalNow, GetTimestamp
bool isNonDeterministicMethod = invokedMethod.Name is "GetUtcNow" or "GetLocalNow" or "GetTimestamp";

if (isNonDeterministicMethod)
{
// e.g.: "The method 'Method1' uses 'System.TimeProvider.GetUtcNow()' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'"
string timeProviderMethodName = $"{invokedMethod.ContainingType}.{invokedMethod.Name}()";
reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, methodSymbol.Name, timeProviderMethodName, orchestrationName));
}
}
}
}
}
}
158 changes: 121 additions & 37 deletions src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Immutable;
using System.Composition;
using System.Globalization;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
Expand All @@ -26,51 +27,89 @@ public sealed class DateTimeOrchestrationFixer : OrchestrationContextFixer
/// <inheritdoc/>
protected override void RegisterCodeFixes(CodeFixContext context, OrchestrationCodeFixContext orchestrationContext)
{
// Parses the syntax node to see if it is a member access expression (e.g. DateTime.Now or DateTimeOffset.Now)
if (orchestrationContext.SyntaxNodeWithDiagnostic is not MemberAccessExpressionSyntax dateTimeExpression)
{
return;
}

// Gets the name of the TaskOrchestrationContext parameter (e.g. "context" or "ctx")
string contextParameterName = orchestrationContext.TaskOrchestrationContextSymbol.Name;

// Use semantic analysis to determine if this is a DateTimeOffset expression
SemanticModel semanticModel = orchestrationContext.SemanticModel;
ITypeSymbol? typeSymbol = semanticModel.GetTypeInfo(dateTimeExpression.Expression).Type;
bool isDateTimeOffset = typeSymbol?.ToDisplayString() == "System.DateTimeOffset";

bool isDateTimeToday = dateTimeExpression.Name.ToString() == "Today";

// Build the recommendation text
string recommendation;
if (isDateTimeOffset)
// Handle DateTime/DateTimeOffset property access (e.g. DateTime.Now or DateTimeOffset.Now)
if (orchestrationContext.SyntaxNodeWithDiagnostic is MemberAccessExpressionSyntax dateTimeExpression)
{
// For DateTimeOffset, we always just cast CurrentUtcDateTime
recommendation = $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime";
// Use semantic analysis to determine if this is a DateTimeOffset expression
ITypeSymbol? typeSymbol = semanticModel.GetTypeInfo(dateTimeExpression.Expression).Type;
bool isDateTimeOffset = typeSymbol?.ToDisplayString() == "System.DateTimeOffset";

bool isDateTimeToday = dateTimeExpression.Name.ToString() == "Today";

// Build the recommendation text
string recommendation;
if (isDateTimeOffset)
{
// For DateTimeOffset, we always just cast CurrentUtcDateTime
recommendation = $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime";
}
else
{
// For DateTime, we may need to add .Date for Today
string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty;
recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}";
}

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'DateTime.Now'"
// e.g: "Use 'context.CurrentUtcDateTime.Date' instead of 'DateTime.Today'"
// e.g: "Use '(DateTimeOffset)context.CurrentUtcDateTime' instead of 'DateTimeOffset.Now'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
dateTimeExpression);

context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday, isDateTimeOffset),
equivalenceKey: title), // This key is used to prevent duplicate code fixes.
context.Diagnostics);
return;
}
else

// Handle TimeProvider method invocations (e.g. TimeProvider.System.GetUtcNow())
// The node might be the invocation itself or a child node, so we need to find the InvocationExpressionSyntax
InvocationExpressionSyntax? timeProviderInvocation = orchestrationContext.SyntaxNodeWithDiagnostic as InvocationExpressionSyntax
?? orchestrationContext.SyntaxNodeWithDiagnostic.AncestorsAndSelf().OfType<InvocationExpressionSyntax>().FirstOrDefault();

if (timeProviderInvocation != null &&
semanticModel.GetSymbolInfo(timeProviderInvocation).Symbol is IMethodSymbol methodSymbol)
{
// For DateTime, we may need to add .Date for Today
string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty;
recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}";
}
string methodName = methodSymbol.Name;

// Check if the method returns DateTimeOffset
bool returnsDateTimeOffset = methodSymbol.ReturnType.ToDisplayString() == "System.DateTimeOffset";

// Build the recommendation based on the method name
string recommendation = methodName switch
{
"GetUtcNow" when returnsDateTimeOffset => $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime",
"GetUtcNow" => $"{contextParameterName}.CurrentUtcDateTime",
"GetLocalNow" when returnsDateTimeOffset => $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime.ToLocalTime()",
"GetLocalNow" => $"{contextParameterName}.CurrentUtcDateTime.ToLocalTime()",
"GetTimestamp" => $"{contextParameterName}.CurrentUtcDateTime.Ticks",
_ => $"{contextParameterName}.CurrentUtcDateTime",
};

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'TimeProvider.System.GetUtcNow()'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
timeProviderInvocation);

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'DateTime.Now'"
// e.g: "Use 'context.CurrentUtcDateTime.Date' instead of 'DateTime.Today'"
// e.g: "Use '(DateTimeOffset)context.CurrentUtcDateTime' instead of 'DateTimeOffset.Now'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
dateTimeExpression.ToString());

context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday, isDateTimeOffset),
equivalenceKey: title), // This key is used to prevent duplicate code fixes.
context.Diagnostics);
context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceTimeProvider(context.Document, orchestrationContext.Root, timeProviderInvocation, contextParameterName, methodName, returnsDateTimeOffset),
equivalenceKey: title),
context.Diagnostics);
}
}

static Task<Document> ReplaceDateTime(Document document, SyntaxNode oldRoot, MemberAccessExpressionSyntax incorrectDateTimeSyntax, string contextParameterName, bool isDateTimeToday, bool isDateTimeOffset)
Expand Down Expand Up @@ -106,4 +145,49 @@ static Task<Document> ReplaceDateTime(Document document, SyntaxNode oldRoot, Mem

return Task.FromResult(newDocument);
}

static Task<Document> ReplaceTimeProvider(Document document, SyntaxNode oldRoot, InvocationExpressionSyntax incorrectTimeProviderSyntax, string contextParameterName, string methodName, bool returnsDateTimeOffset)
{
// Build the correct expression based on the method name
ExpressionSyntax correctExpression = methodName switch
{
"GetUtcNow" => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
"GetLocalNow" => InvocationExpression(
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
IdentifierName("ToLocalTime"))),
"GetTimestamp" => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
IdentifierName("Ticks")),
_ => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
};

// If the method returns DateTimeOffset, we need to cast the DateTime to DateTimeOffset
if (returnsDateTimeOffset)
{
correctExpression = CastExpression(
IdentifierName("DateTimeOffset"),
correctExpression);
}

// Replaces the old invocation with the new expression
SyntaxNode newRoot = oldRoot.ReplaceNode(incorrectTimeProviderSyntax, correctExpression);
Document newDocument = document.WithSyntaxRoot(newRoot);

return Task.FromResult(newDocument);
}
}
Loading
Loading