diff --git a/README.md b/README.md index 9d50f72b3..a4aa5d82b 100755 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️| |[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|ℹ️|❌|❌| |[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|ℹ️|❌|✔️| +|[MA0188](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0188.md)|Design|Use System.TimeProvider instead of a custom time abstraction|ℹ️|✔️|❌| diff --git a/docs/README.md b/docs/README.md index 832ad560f..d99d63b14 100755 --- a/docs/README.md +++ b/docs/README.md @@ -186,6 +186,7 @@ |[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️| |[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|ℹ️|❌|❌| |[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|ℹ️|❌|✔️| +|[MA0188](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0188.md)|Design|Use System.TimeProvider instead of a custom time abstraction|ℹ️|✔️|❌| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -759,6 +760,9 @@ dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute dotnet_diagnostic.MA0187.severity = none + +# MA0188: Use System.TimeProvider instead of a custom time abstraction +dotnet_diagnostic.MA0188.severity = suggestion ``` # .editorconfig - all rules disabled @@ -1318,4 +1322,7 @@ dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute dotnet_diagnostic.MA0187.severity = none + +# MA0188: Use System.TimeProvider instead of a custom time abstraction +dotnet_diagnostic.MA0188.severity = none ``` diff --git a/docs/Rules/MA0188.md b/docs/Rules/MA0188.md new file mode 100644 index 000000000..4f5f9b6fa --- /dev/null +++ b/docs/Rules/MA0188.md @@ -0,0 +1,39 @@ +# MA0188 - Use System.TimeProvider instead of a custom time abstraction + +Source: [UseTimeProviderInsteadOfInterfaceAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs) + + +`System.TimeProvider` (available since .NET 8) provides a standard abstraction for time-related operations. Defining a custom interface for this purpose is unnecessary when `System.TimeProvider` covers the same use case. For older target frameworks, the [`Microsoft.Bcl.TimeProvider`](https://www.nuget.org/packages/Microsoft.Bcl.TimeProvider) NuGet package provides a backport. + +This rule reports interfaces whose members consist entirely of time-related properties or methods (`Now`, `UtcNow`, `GetNow()`, `GetUtcNow()`) returning `DateTime` or `DateTimeOffset`. + +## Non-compliant code + +```csharp +interface ITimeProvider +{ + System.DateTime UtcNow { get; } +} +``` + +```csharp +interface ITimeService +{ + System.DateTimeOffset GetUtcNow(); +} +``` + +## Compliant code + +Use `System.TimeProvider` directly instead of defining a custom interface: + +```csharp +class Sample +{ + void Usage(System.TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + } +} +``` + diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index cc2a14297..5ebd91120 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -556,3 +556,6 @@ dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute dotnet_diagnostic.MA0187.severity = none + +# MA0188: Use System.TimeProvider instead of a custom time abstraction +dotnet_diagnostic.MA0188.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index cca6726d6..c469c38a6 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -556,3 +556,6 @@ dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute dotnet_diagnostic.MA0187.severity = none + +# MA0188: Use System.TimeProvider instead of a custom time abstraction +dotnet_diagnostic.MA0188.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 95da6fa0a..d075dd5fe 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -187,6 +187,7 @@ internal static class RuleIdentifiers public const string SimplifyStringCreateWhenAllParametersAreCultureInvariant = "MA0185"; public const string MissingNotNullWhenAttributeOnEquals = "MA0186"; public const string BlazorPropertyInjectionShouldUseConstructorInjection = "MA0187"; + public const string UseTimeProviderInsteadOfInterface = "MA0188"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs b/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs new file mode 100644 index 000000000..bbbd45573 --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseTimeProviderInsteadOfInterfaceAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.UseTimeProviderInsteadOfInterface, + title: "Use System.TimeProvider instead of a custom time abstraction", + messageFormat: "Use System.TimeProvider instead of defining a custom time abstraction", + RuleCategories.Design, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.UseTimeProviderInsteadOfInterface)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(ctx => + { + var dateTimeSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.DateTime"); + var dateTimeOffsetSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.DateTimeOffset"); + + if (dateTimeSymbol is null && dateTimeOffsetSymbol is null) + return; + + ctx.RegisterSymbolAction(ctx => AnalyzeNamedType(ctx, dateTimeSymbol, dateTimeOffsetSymbol), SymbolKind.NamedType); + }); + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol) + { + var type = (INamedTypeSymbol)context.Symbol; + if (type.TypeKind is not TypeKind.Interface) + return; + + // Must have at least one member + var members = type.GetMembers(); + if (members.IsEmpty) + return; + + // All members must be time-provider-like (skip property accessor methods as they're covered by property symbols) + foreach (var member in members) + { + // Skip property accessor methods - they are represented by the IPropertySymbol + if (member is IMethodSymbol { MethodKind: MethodKind.PropertyGet or MethodKind.PropertySet }) + continue; + + if (!IsTimeProviderLikeMember(member, dateTimeSymbol, dateTimeOffsetSymbol)) + return; + } + + context.ReportDiagnostic(Rule, type); + } + + private static bool IsTimeProviderLikeMember(ISymbol member, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol) + { + if (member.IsStatic) + return false; + + if (member is IPropertySymbol property) + { + if (property.Name is not ("Now" or "UtcNow" or "GetNow" or "GetUtcNow" or "CurrentTime")) + return false; + + return IsDateTimeOrDateTimeOffset(property.Type, dateTimeSymbol, dateTimeOffsetSymbol); + } + + if (member is IMethodSymbol method) + { + // Exclude property accessor methods + if (method.MethodKind is not MethodKind.Ordinary) + return false; + + if (method.Name is not ("Now" or "UtcNow" or "GetNow" or "GetUtcNow" or "CurrentTime")) + return false; + + if (!method.Parameters.IsEmpty) + return false; + + return IsDateTimeOrDateTimeOffset(method.ReturnType, dateTimeSymbol, dateTimeOffsetSymbol); + } + + return false; + } + + private static bool IsDateTimeOrDateTimeOffset(ITypeSymbol type, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol) + { + return type.IsEqualTo(dateTimeSymbol) || type.IsEqualTo(dateTimeOffsetSymbol); + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs new file mode 100644 index 000000000..59135d5a1 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs @@ -0,0 +1,197 @@ +using Meziantou.Analyzer.Rules; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class UseTimeProviderInsteadOfInterfaceAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer(); + } + + [Fact] + public async Task Interface_UtcNowProperty_DateTime_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTime UtcNow { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_NowProperty_DateTimeOffset_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTimeOffset Now { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_BothNowAndUtcNow_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTime Now { get; } + System.DateTime UtcNow { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_GetNowMethod_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTime GetNow(); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_GetUtcNowMethod_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTimeOffset GetUtcNow(); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_MixedProperties_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeService|] + { + System.DateTime GetNow(); + System.DateTimeOffset GetUtcNow(); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_EmptyInterface_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface IEmpty + { + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_OtherMembers_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface ITimeProvider + { + System.DateTime UtcNow { get; } + void DoSomething(); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_WrongReturnType_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface ITimeProvider + { + string UtcNow { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_MethodWithParameters_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface ITimeProvider + { + System.DateTime GetNow(string timeZone); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_CurrentTimeProperty_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface [|ITimeProvider|] + { + System.DateTime CurrentTime { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_WrongName_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface ITimeProvider + { + System.DateTime GetCurrentTime(); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Class_NotAnInterface_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class TimeProvider + { + public System.DateTime UtcNow { get; } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task Interface_StaticMember_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + interface ITimeProvider + { + static System.DateTime UtcNow { get; } + } + """) + .ValidateAsync(); + } +}