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();
+ }
+}