diff --git a/README.md b/README.md index c6c2de17..89dfc214 100755 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0175](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0175.md)|Style|Record should not use explicit 'class' keyword|ℹ️|❌|❌| |[MA0176](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0176.md)|Performance|Optimize guid creation|ℹ️|✔️|✔️| |[MA0177](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0177.md)|Style|Use single-line XML comment syntax when possible|ℹ️|❌|✔️| +|[MA0178](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0178.md)|Design|Use TimeSpan.Zero instead of TimeSpan.FromXXX(0)|ℹ️|✔️|✔️| diff --git a/docs/README.md b/docs/README.md index a44bc69d..e9097a21 100755 --- a/docs/README.md +++ b/docs/README.md @@ -177,6 +177,7 @@ |[MA0175](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0175.md)|Style|Record should not use explicit 'class' keyword|ℹ️|❌|❌| |[MA0176](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0176.md)|Performance|Optimize guid creation|ℹ️|✔️|✔️| |[MA0177](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0177.md)|Style|Use single-line XML comment syntax when possible|ℹ️|❌|✔️| +|[MA0178](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0178.md)|Design|Use TimeSpan.Zero instead of TimeSpan.FromXXX(0)|ℹ️|✔️|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -716,6 +717,9 @@ dotnet_diagnostic.MA0176.severity = suggestion # MA0177: Use single-line XML comment syntax when possible dotnet_diagnostic.MA0177.severity = none + +# MA0178: Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) +dotnet_diagnostic.MA0178.severity = suggestion ``` # .editorconfig - all rules disabled @@ -1248,4 +1252,7 @@ dotnet_diagnostic.MA0176.severity = none # MA0177: Use single-line XML comment syntax when possible dotnet_diagnostic.MA0177.severity = none + +# MA0178: Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) +dotnet_diagnostic.MA0178.severity = none ``` diff --git a/docs/Rules/MA0178.md b/docs/Rules/MA0178.md new file mode 100644 index 00000000..4758680e --- /dev/null +++ b/docs/Rules/MA0178.md @@ -0,0 +1,20 @@ +# MA0178 - Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) + +Sources: [UseTimeSpanZeroAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/UseTimeSpanZeroAnalyzer.cs), [UseTimeSpanZeroFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/UseTimeSpanZeroFixer.cs) + + +You should use `TimeSpan.Zero` instead of `TimeSpan.FromXXX(0)` for better readability and to express the intent more clearly. + +````csharp +TimeSpan.FromSeconds(0); // non-compliant +TimeSpan.FromMinutes(0); // non-compliant +TimeSpan.FromHours(0); // non-compliant +TimeSpan.FromDays(0); // non-compliant +TimeSpan.FromMilliseconds(0); // non-compliant +TimeSpan.FromMicroseconds(0); // non-compliant +TimeSpan.FromTicks(0); // non-compliant + +// Should be +TimeSpan.Zero; // compliant +```` + diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/UseTimeSpanZeroFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/UseTimeSpanZeroFixer.cs new file mode 100644 index 00000000..6ea45ef0 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/UseTimeSpanZeroFixer.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using System.Composition; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Editing; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class UseTimeSpanZeroFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.UseTimeSpanZero); + + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var nodeToFix = root?.FindNode(context.Span, getInnermostNodeForTie: true); + if (nodeToFix is null) + return; + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + return; + + var timeSpanType = semanticModel.Compilation.GetBestTypeByMetadataName("System.TimeSpan"); + if (timeSpanType is null) + return; + + var title = "Use TimeSpan.Zero"; + var codeAction = CodeAction.Create( + title, + ct => ConvertToTimeSpanZero(context.Document, nodeToFix, ct), + equivalenceKey: title); + + context.RegisterCodeFix(codeAction, context.Diagnostics); + } + + private static async Task ConvertToTimeSpanZero(Document document, SyntaxNode nodeToFix, CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + var semanticModel = editor.SemanticModel; + var generator = editor.Generator; + + editor.ReplaceNode(nodeToFix, GenerateTimeSpanZeroExpression(generator, semanticModel).WithTriviaFrom(nodeToFix)); + return editor.GetChangedDocument(); + } + + private static SyntaxNode GenerateTimeSpanZeroExpression(SyntaxGenerator generator, SemanticModel semanticModel) + { + var timeSpanType = semanticModel.Compilation.GetBestTypeByMetadataName("System.TimeSpan"); + return generator.MemberAccessExpression( + generator.TypeExpression(timeSpanType!), + nameof(TimeSpan.Zero)); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index e7a44d2b..02aff553 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -529,3 +529,6 @@ dotnet_diagnostic.MA0176.severity = suggestion # MA0177: Use single-line XML comment syntax when possible dotnet_diagnostic.MA0177.severity = none + +# MA0178: Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) +dotnet_diagnostic.MA0178.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 29afc8b8..e7862daf 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -529,3 +529,6 @@ dotnet_diagnostic.MA0176.severity = none # MA0177: Use single-line XML comment syntax when possible dotnet_diagnostic.MA0177.severity = none + +# MA0178: Use TimeSpan.Zero instead of TimeSpan.FromXXX(0) +dotnet_diagnostic.MA0178.severity = none diff --git a/src/Meziantou.Analyzer/Internals/NumericHelpers.cs b/src/Meziantou.Analyzer/Internals/NumericHelpers.cs new file mode 100644 index 00000000..7297ad8c --- /dev/null +++ b/src/Meziantou.Analyzer/Internals/NumericHelpers.cs @@ -0,0 +1,23 @@ +namespace Meziantou.Analyzer.Internals; + +internal static class NumericHelpers +{ + public static bool IsZero(object? value) + { + return value switch + { + int i => i == 0, + long l => l == 0, + double d => d == 0.0, + float f => f == 0.0f, + decimal dec => dec == 0m, + byte b => b == 0, + sbyte sb => sb == 0, + short s => s == 0, + ushort us => us == 0, + uint ui => ui == 0, + ulong ul => ul == 0, + _ => false, + }; + } +} diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 08fa9ad2..3bf49361 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -178,6 +178,7 @@ internal static class RuleIdentifiers public const string RecordClassDeclarationShouldBeImplicit = "MA0175"; public const string OptimizeGuidCreation = "MA0176"; public const string UseSingleLineXmlCommentSyntaxWhenPossible = "MA0177"; + public const string UseTimeSpanZero = "MA0178"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/NonFlagsEnumsShouldNotBeMarkedWithFlagsAttributeAnalyzer.cs b/src/Meziantou.Analyzer/Rules/NonFlagsEnumsShouldNotBeMarkedWithFlagsAttributeAnalyzer.cs index 3f6131d3..a6625446 100644 --- a/src/Meziantou.Analyzer/Rules/NonFlagsEnumsShouldNotBeMarkedWithFlagsAttributeAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/NonFlagsEnumsShouldNotBeMarkedWithFlagsAttributeAnalyzer.cs @@ -44,7 +44,7 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) var members = symbol.GetMembers() .OfType() .Where(member => member.ConstantValue is not null) - .Select(member => (member, IsSingleBitSet: IsSingleBitSet(member.ConstantValue), IsZero: IsZero(member.ConstantValue))) + .Select(member => (member, IsSingleBitSet: IsSingleBitSet(member.ConstantValue), IsZero: NumericHelpers.IsZero(member.ConstantValue))) .ToArray(); foreach (var member in members) { @@ -66,7 +66,7 @@ private static void AnalyzeSymbol(SymbolAnalysisContext context) } } - if (!IsZero(value)) + if (!NumericHelpers.IsZero(value)) { context.ReportDiagnostic(Rule, symbol, member.member.Name); return; @@ -93,24 +93,7 @@ private static bool IsSingleBitSet(object? o) }; } - private static bool IsZero(object? o) - { - // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/enum?WT.mc_id=DT-MVP-5003978 - // The approved types for an enum are byte, sbyte, short, ushort, int, uint, long, or ulong. - return o switch - { - null => false, - byte x => x == 0, - sbyte x => x == 0, - short x => x == 0, - ushort x => x == 0, - int x => x == 0, - uint x => x == 0, - long x => x == 0, - ulong x => x == 0, - _ => throw new ArgumentOutOfRangeException(nameof(o), $"Type {o.GetType().FullName} is not supported"), - }; - } + private static bool IsAllBitsSet(object? o) { diff --git a/src/Meziantou.Analyzer/Rules/UseTimeSpanZeroAnalyzer.cs b/src/Meziantou.Analyzer/Rules/UseTimeSpanZeroAnalyzer.cs new file mode 100644 index 00000000..318cc59c --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/UseTimeSpanZeroAnalyzer.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseTimeSpanZeroAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.UseTimeSpanZero, + title: "Use TimeSpan.Zero instead of TimeSpan.FromXXX(0)", + messageFormat: "Use TimeSpan.Zero instead of TimeSpan.{0}(0)", + RuleCategories.Design, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.UseTimeSpanZero)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationContext => + { + var timeSpanType = compilationContext.Compilation.GetBestTypeByMetadataName("System.TimeSpan"); + if (timeSpanType is null) + return; + + compilationContext.RegisterOperationAction(context => AnalyzeInvocationOperation(context, timeSpanType), OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocationOperation(OperationAnalysisContext context, INamedTypeSymbol timeSpanType) + { + var operation = (IInvocationOperation)context.Operation; + + // Check if the method is a static method on System.TimeSpan + if (!operation.TargetMethod.IsStatic) + return; + + if (!operation.TargetMethod.ContainingType.IsEqualTo(timeSpanType)) + return; + + // Check if it's one of the From methods + var methodName = operation.TargetMethod.Name; + if (!IsTimeSpanFromMethod(methodName)) + return; + + // Check if the method has exactly one argument + if (operation.Arguments.Length == 0) + return; + + // Check if the argument is a constant value of 0 + foreach (var argument in operation.Arguments) + { + if (!argument.Value.ConstantValue.HasValue) + return; + + var constantValue = argument.Value.ConstantValue.Value; + if (!NumericHelpers.IsZero(constantValue)) + return; + } + + context.ReportDiagnostic(Rule, operation, methodName); + } + + private static bool IsTimeSpanFromMethod(string methodName) + { + return methodName is "FromDays" or "FromHours" or "FromMinutes" or "FromSeconds" or "FromMilliseconds" or "FromMicroseconds" or "FromTicks"; + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/UseTimeSpanZeroAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/UseTimeSpanZeroAnalyzerTests.cs new file mode 100644 index 00000000..e8f1619a --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/UseTimeSpanZeroAnalyzerTests.cs @@ -0,0 +1,111 @@ +using Meziantou.Analyzer.Rules; +using TestHelper; +using Xunit; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class UseTimeSpanZeroAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider() + .WithTargetFramework(Helpers.TargetFramework.NetLatest); + } + + [Theory] + [InlineData("System.TimeSpan.FromSeconds(0)")] + [InlineData("System.TimeSpan.FromSeconds(0.0)")] + [InlineData("System.TimeSpan.FromMinutes(0)")] + [InlineData("System.TimeSpan.FromMinutes(0.0)")] + [InlineData("System.TimeSpan.FromHours(0)")] + [InlineData("System.TimeSpan.FromHours(0.0)")] + [InlineData("System.TimeSpan.FromDays(0)")] + [InlineData("System.TimeSpan.FromDays(0.0)")] + [InlineData("System.TimeSpan.FromMilliseconds(0)")] + [InlineData("System.TimeSpan.FromMilliseconds(0L)")] + [InlineData("System.TimeSpan.FromMilliseconds(0L, 0L)")] + [InlineData("System.TimeSpan.FromMilliseconds(0.0)")] + [InlineData("System.TimeSpan.FromMilliseconds(0.0d)")] + [InlineData("System.TimeSpan.FromMicroseconds(0)")] + [InlineData("System.TimeSpan.FromMicroseconds(0.0)")] + [InlineData("System.TimeSpan.FromTicks(0)")] + [InlineData("System.TimeSpan.FromTicks(0L)")] + public async Task ShouldReportDiagnostic(string code) + { + await CreateProjectBuilder() + .WithSourceCode($$""" +class TestClass +{ + void Test() + { + _ = [||]{{code}}; + } +} +""") + .ShouldFixCodeWith(""" +class TestClass +{ + void Test() + { + _ = System.TimeSpan.Zero; + } +} +""") + .ValidateAsync(); + } + + [Theory] + [InlineData("System.TimeSpan.FromSeconds(1)")] + [InlineData("System.TimeSpan.FromSeconds(0.5)")] + [InlineData("System.TimeSpan.FromMinutes(1)")] + [InlineData("System.TimeSpan.FromHours(1)")] + [InlineData("System.TimeSpan.FromDays(1)")] + [InlineData("System.TimeSpan.FromMilliseconds(100)")] + [InlineData("System.TimeSpan.FromMicroseconds(1)")] + [InlineData("System.TimeSpan.FromTicks(1)")] + [InlineData("System.TimeSpan.Zero")] + [InlineData("new System.TimeSpan()")] + [InlineData("new System.TimeSpan(0)")] + [InlineData("default(System.TimeSpan)")] + public async Task ShouldNotReportDiagnostic(string code) + { + await CreateProjectBuilder() + .WithSourceCode($$""" +class TestClass +{ + void Test() + { + _ = {{code}}; + } +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task ShouldReportDiagnostic_MultipleOccurrences() + { + await CreateProjectBuilder() + .WithSourceCode(""" +class TestClass +{ + void Test() + { + _ = [||]System.TimeSpan.FromSeconds(0); + } +} +""") + .ShouldFixCodeWith(""" +class TestClass +{ + void Test() + { + _ = System.TimeSpan.Zero; + } +} +""") + .ValidateAsync(); + } +}