Skip to content

Commit 1f87790

Browse files
committed
feat: Introduce analyzer to warn against DateTime.Now and DateTime.UtcNow in favor of DateTimeOffset.UtcNow, including tests.
1 parent 8bf2b3e commit 1f87790

7 files changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using BookStore.ApiService.Analyzers.Analyzers;
2+
using Microsoft.CodeAnalysis.Testing;
3+
using TUnit.Core;
4+
5+
namespace BookStore.ApiService.Analyzers.Tests.Analyzers;
6+
7+
public class UseDateTimeOffsetUtcNowAnalyzerTests
8+
{
9+
[Test]
10+
[Arguments("TestData/BS1007/NoDiagnostic/ValidDateTimeUsage.cs")]
11+
public async Task Verify_NoDiagnostics(string path)
12+
{
13+
var source = await File.ReadAllTextAsync(path);
14+
await CSharpAnalyzerVerifier<UseDateTimeOffsetUtcNowAnalyzer>.VerifyAnalyzerAsync(source);
15+
}
16+
17+
[Test]
18+
[Arguments("TestData/BS1007/Diagnostic/InvalidDateTimeNow.cs", 10, 19, "DateTime.Now")]
19+
[Arguments("TestData/BS1007/Diagnostic/InvalidDateTimeUtcNow.cs", 10, 19, "DateTime.UtcNow")]
20+
[Arguments("TestData/BS1007/Diagnostic/InvalidDateTimeInField.cs", 8, 35, "DateTime.Now")]
21+
public async Task Verify_Diagnostics(string path, int line, int column, string dateTimeUsage)
22+
{
23+
var source = await File.ReadAllTextAsync(path);
24+
25+
var expected = CSharpAnalyzerVerifier<UseDateTimeOffsetUtcNowAnalyzer>
26+
.Diagnostic(DiagnosticIds.UseDateTimeOffsetUtcNow)
27+
.WithLocation(line, column)
28+
.WithArguments(dateTimeUsage);
29+
30+
await CSharpAnalyzerVerifier<UseDateTimeOffsetUtcNowAnalyzer>.VerifyAnalyzerAsync(source, expected);
31+
}
32+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System;
2+
3+
namespace BookStore.ApiService.Tests;
4+
5+
// Invalid: Using DateTime.Now in field initializer
6+
public class InvalidDateTimeInField
7+
{
8+
private DateTime _timestamp = DateTime.Now;
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System;
2+
3+
namespace BookStore.ApiService.Tests;
4+
5+
// Invalid: Using DateTime.Now
6+
public class InvalidDateTimeNow
7+
{
8+
public void CreateEvent()
9+
{
10+
var now = DateTime.Now;
11+
}
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System;
2+
3+
namespace BookStore.ApiService.Tests;
4+
5+
// Invalid: Using DateTime.UtcNow
6+
public class InvalidDateTimeUtcNow
7+
{
8+
public void CreateEvent()
9+
{
10+
var now = DateTime.UtcNow;
11+
}
12+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
3+
namespace BookStore.ApiService.Tests;
4+
5+
// Valid: Using DateTimeOffset.UtcNow
6+
public class ValidDateTimeUsage
7+
{
8+
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
9+
10+
public void CreateEvent()
11+
{
12+
var now = DateTimeOffset.UtcNow;
13+
var parsed = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
14+
var min = DateTimeOffset.MinValue;
15+
}
16+
}
17+
18+
public record ValidEvent(DateTimeOffset Timestamp)
19+
{
20+
public static ValidEvent Create() => new(DateTimeOffset.UtcNow);
21+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace BookStore.ApiService.Analyzers.Analyzers;
8+
9+
/// <summary>
10+
/// Analyzer to enforce the use of DateTimeOffset.UtcNow instead of DateTime.Now (BS1007)
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public class UseDateTimeOffsetUtcNowAnalyzer : DiagnosticAnalyzer
14+
{
15+
static readonly DiagnosticDescriptor Rule = new(
16+
id: DiagnosticIds.UseDateTimeOffsetUtcNow,
17+
title: "Use DateTimeOffset.UtcNow instead of DateTime.Now",
18+
messageFormat: "Use 'DateTimeOffset.UtcNow' instead of '{0}' for timezone-aware timestamps",
19+
category: DiagnosticCategories.BestPractices,
20+
defaultSeverity: DiagnosticSeverity.Warning,
21+
isEnabledByDefault: true,
22+
description: "DateTimeOffset.UtcNow provides timezone-aware timestamps and is preferred over DateTime.Now or DateTime.UtcNow for distributed systems and event sourcing.");
23+
24+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
25+
26+
public override void Initialize(AnalysisContext context)
27+
{
28+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
29+
context.EnableConcurrentExecution();
30+
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
31+
}
32+
33+
static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
34+
{
35+
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
36+
37+
// Check if this is accessing Now or UtcNow
38+
var memberName = memberAccess.Name.Identifier.Text;
39+
if (memberName != "Now" && memberName != "UtcNow")
40+
{
41+
return;
42+
}
43+
44+
// Get the symbol information
45+
var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken);
46+
if (symbolInfo.Symbol is not IPropertySymbol propertySymbol)
47+
{
48+
return;
49+
}
50+
51+
// Check if this is DateTime.Now or DateTime.UtcNow
52+
var containingType = propertySymbol.ContainingType?.ToDisplayString();
53+
if (containingType == "System.DateTime")
54+
{
55+
var diagnostic = Diagnostic.Create(
56+
Rule,
57+
memberAccess.GetLocation(),
58+
$"DateTime.{memberName}");
59+
context.ReportDiagnostic(diagnostic);
60+
}
61+
}
62+
}

src/ApiService/BookStore.ApiService.Analyzers/DiagnosticIds.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static class DiagnosticIds
1414

1515
// Best Practices (BS1xxx continued)
1616
public const string UseCreateVersion7 = "BS1006";
17+
public const string UseDateTimeOffsetUtcNow = "BS1007";
1718

1819
// Command Rules (BS2xxx)
1920
public const string CommandMustBeRecord = "BS2001";

0 commit comments

Comments
 (0)