diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs index f2fec53a..b580eae0 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs @@ -40,7 +40,7 @@ static void Main(string[] args) } [TestMethod] - public void UsageOfImplicitConversionInComparison_ProducesWarningMessage() + public void UsageOfImplicitConversionInComparison_DateTimeToDateTimeOffset_ProducesWarningMessage() { string source = @" using System; @@ -53,15 +53,40 @@ internal class Program static void Main(string[] args) { DateTime first = DateTime.Now; + DateTimeOffset second = DateTimeOffset.Now; + _ = first < second; + } + } +}"; - Thread.Sleep(10); + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = + [new DiagnosticResultLocation("Test0.cs", 13, 17)] + }); - DateTimeOffset second = DateTimeOffset.Now; + } - if (first < second) - { - Console.WriteLine(""Time has passed...""); - } + [TestMethod] + public void UsageOfImplicitConversionInComparison_DateTimeOffsetToDateTime_ProducesWarningMessage() + { + string source = @" +using System; +using System.Threading; + +namespace ConsoleApp1 +{ + internal class Program + { + static void Main(string[] args) + { + DateTimeOffset first = DateTimeOffset.Now; + DateTime second = DateTime.Now; + _ = first < second; } } }"; @@ -73,13 +98,152 @@ static void Main(string[] args) Severity = DiagnosticSeverity.Warning, Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", Locations = - [ - new DiagnosticResultLocation("Test0.cs", 17, 17) - ] + [new DiagnosticResultLocation("Test0.cs", 13, 25)] }); } + [TestMethod] + public void UsageOfImplicitConversionInComparison_NullableDateTimeOffsetToDateTime_ProducesWarningMessage() + { + string source = @" +using System; +using System.Threading; + +namespace ConsoleApp1 +{ + internal class Program + { + static void Main(string[] args) + { + DateTimeOffset? first = DateTimeOffset.Now; + DateTime second = DateTime.Now; + _ = first < second; + } + } +}"; + + VerifyCSharpDiagnostic( + source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 13, 25)] + } + ); + } + + [Ignore("Property-based conversion detection not yet implemented - requires Roslyn IOperation tree investigation")] + [TestMethod] + public void UsageOfImplicitConversion_WithProperties_ProducesWarningMessage() + { + string source = @" +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ConsoleApp1 +{ + internal class Pair(DateTimeOffset DateTimeOffset, DateTime DateTime); + + internal class Program + { + static void Main(string[] args) + { + Pair pair = new(DateTimeOffset.Now, DateTime.Now); + _ = pair.DateTimeOffset < pair.DateTime; + } + } +}"; + + VerifyCSharpDiagnostic( + source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 14, 29)] + } + ); + } + + [Ignore("Property-based conversion detection not yet implemented - requires Roslyn IOperation tree investigation")] + [TestMethod] + public void UsageOfImplicitConversion_WithPropertiesInLinq_ProducesWarningMessage() + { + string source = @" +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ConsoleApp1 +{ + internal class Pair(DateTimeOffset DateTimeOffset, DateTime DateTime); + + internal class Program + { + static void Main(string[] args) + { + List list = new(){ new(DateTimeOffset.Now, DateTime.Now) }; + _ = list.Where(pair => pair.DateTimeOffset < pair.DateTime); + } + } +}"; + + VerifyCSharpDiagnostic( + source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 14, 42)] + } + ); + } + + [Ignore("Property-based conversion detection not yet implemented - requires Roslyn IOperation tree investigation")] + [TestMethod] + public void UsageOfImplicitConversion_InLinqWithVariables_ProducesWarningMessage() + { + string source = @" +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ConsoleApp1 +{ + internal class Pair(DateTimeOffset DateTimeOffset, DateTime DateTime); + + internal class Program + { + static void Main(string[] args) + { + List list = new(){ new(DateTimeOffset.Now, DateTime.Now) }; + _ = list.Where(pair => { + DateTimeOffset first = pair.DateTimeOffset; + DateTime second = pair.DateTime; + return first < second; + }); + } + } +}"; + + VerifyCSharpDiagnostic( + source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 18, 24)] + } + ); + } + [TestMethod] public void UsageOfExplicitConversion_ProducesNothing() { @@ -101,6 +265,52 @@ static void Main(string[] args) } + [Ignore("Property-based conversion detection not yet implemented - requires Roslyn IOperation tree investigation")] + [TestMethod] + public void UsageInLambdaWithDateProperty_ProducesWarningMessage() + { + // This test matches the original issue scenario + string source = @" +using System; +using System.Linq; + +namespace Test +{ + public class TimeEntry + { + public DateTimeOffset EndDate { get; set; } + public DateTimeOffset StartDate { get; set; } + } + + public class DataSource + { + public IQueryable GetQuery(DateTime startDate, DateTime endDate) + { + return Enumerable.Empty().AsQueryable() + .Where(te => + te.EndDate <= endDate.Date.AddDays(1).AddTicks(-1) && + te.StartDate >= startDate.Date); + } + } +}"; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 18, 35)] + }, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using the symbol 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = [new DiagnosticResultLocation("Test0.cs", 19, 38)] + }); + } + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() { return new Analyzers.BanImplicitDateTimeToDateTimeOffsetConversion(); diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs index 46940659..978467b0 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Immutable; +using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -28,27 +30,88 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); context.EnableConcurrentExecution(); - context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Conversion); + context.RegisterOperationAction(AnalyzeConversion, OperationKind.Conversion); + context.RegisterOperationAction(AnalyzeBinaryOperation, OperationKind.Binary); } - private void AnalyzeInvocation(OperationAnalysisContext context) + private void AnalyzeConversion(OperationAnalysisContext context) { - if (context.Operation is not IConversionOperation conversionOperation) + if (context.Operation is not IConversionOperation conversionOperation || !conversionOperation.Conversion.IsImplicit) { return; } - if (conversionOperation.Conversion.IsImplicit && conversionOperation.Conversion.MethodSymbol is object && conversionOperation.Conversion.MethodSymbol.ContainingType is object) + ITypeSymbol sourceType = conversionOperation.Operand.Type; + ITypeSymbol targetType = conversionOperation.Type; + + if (sourceType is null || targetType is null) + { + return; + } + + INamedTypeSymbol dateTimeType = context.Compilation.GetTypeByMetadataName("System.DateTime") + ?? throw new InvalidOperationException("System.DateTime type not found in compilation."); + INamedTypeSymbol dateTimeOffsetType = context.Compilation.GetTypeByMetadataName("System.DateTimeOffset") + ?? throw new InvalidOperationException("System.DateTimeOffset type not found in compilation."); + + // Check if source is DateTime and target is DateTimeOffset + if (SymbolEqualityComparer.Default.Equals(sourceType, dateTimeType) && + SymbolEqualityComparer.Default.Equals(targetType, dateTimeOffsetType)) + { + // Report the diagnostic at the operand's location (the DateTime expression being converted) + context.ReportDiagnostic(Diagnostic.Create(_Rule202, conversionOperation.Operand.Syntax.GetLocation())); + } + } + + private void AnalyzeBinaryOperation(OperationAnalysisContext context) + { + if (context.Operation is not IBinaryOperation binaryOperation) + { + return; + } + + INamedTypeSymbol dateTimeType = context.Compilation.GetTypeByMetadataName("System.DateTime") + ?? throw new InvalidOperationException("System.DateTime type not found in compilation."); + INamedTypeSymbol dateTimeOffsetType = context.Compilation.GetTypeByMetadataName("System.DateTimeOffset") + ?? throw new InvalidOperationException("System.DateTimeOffset type not found in compilation."); + + // For binary operations, check if either operand is DateTime and the other is DateTimeOffset + // This catches cases where IConversionOperation nodes are not created (e.g., property access) + CheckBinaryOperandPair(context, binaryOperation.LeftOperand, binaryOperation.RightOperand, dateTimeType, dateTimeOffsetType); + CheckBinaryOperandPair(context, binaryOperation.RightOperand, binaryOperation.LeftOperand, dateTimeType, dateTimeOffsetType); + } + + private void CheckBinaryOperandPair(OperationAnalysisContext context, IOperation operand, IOperation otherOperand, INamedTypeSymbol dateTimeType, INamedTypeSymbol dateTimeOffsetType) + { + if (operand?.Type is null || otherOperand?.Type is null) + { + return; + } + + // Skip if we don't have a syntax location to report + if (operand.Syntax is null) { - INamedTypeSymbol containingType = conversionOperation.Conversion.MethodSymbol.ContainingType; - INamedTypeSymbol dateTimeOffsetType = context.Compilation.GetTypeByMetadataName("System.DateTimeOffset"); - if (SymbolEqualityComparer.Default.Equals(containingType, dateTimeOffsetType)) - { - context.ReportDiagnostic(Diagnostic.Create(_Rule202, conversionOperation.Syntax.GetLocation())); - } + return; } + // Unwrap nullable types if present + ITypeSymbol operandType = operand.Type is INamedTypeSymbol { IsValueType: true, OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : operand.Type; + + ITypeSymbol otherType = otherOperand.Type is INamedTypeSymbol { IsValueType: true, OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } otherNullable + ? otherNullable.TypeArguments[0] + : otherOperand.Type; + // Check if operand is DateTime and other operand is DateTimeOffset + bool isDateTimeOperand = SymbolEqualityComparer.Default.Equals(operandType, dateTimeType); + bool isDateTimeOffsetOtherOperand = SymbolEqualityComparer.Default.Equals(otherType, dateTimeOffsetType); + + if (isDateTimeOperand && isDateTimeOffsetOtherOperand) + { + // DateTime will be implicitly converted to DateTimeOffset in the comparison + context.ReportDiagnostic(Diagnostic.Create(_Rule202, operand.Syntax.GetLocation())); + } } private static class Rule202