diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4891cb8c..d7c1e8c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,12 +67,14 @@ jobs: - run: dotnet build src/Meziantou.Analyzer/Meziantou.Analyzer.csproj --configuration Release /p:RoslynVersion=roslyn4.4 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.roslyn4.4.binlog - run: dotnet build src/Meziantou.Analyzer/Meziantou.Analyzer.csproj --configuration Release /p:RoslynVersion=roslyn4.6 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.roslyn4.6.binlog - run: dotnet build src/Meziantou.Analyzer/Meziantou.Analyzer.csproj --configuration Release /p:RoslynVersion=roslyn4.8 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.roslyn4.8.binlog + - run: dotnet build src/Meziantou.Analyzer/Meziantou.Analyzer.csproj --configuration Release /p:RoslynVersion=roslyn4.14 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.roslyn4.14.binlog - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn3.8 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn3.8.binlog - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn4.2 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn4.2.binlog - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn4.4 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn4.4.binlog - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn4.6 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn4.6.binlog - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn4.8 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn4.8.binlog + - run: dotnet build src/Meziantou.Analyzer.CodeFixers/Meziantou.Analyzer.CodeFixers.csproj --configuration Release /p:RoslynVersion=roslyn4.14 /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.CodeFixers.roslyn4.14.binlog - run: dotnet restore src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj -bl:Meziantou.Analyzer.Pack.restore.binlog - run: dotnet pack src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj --configuration Release --no-build /p:Version=${{ needs.compute_package_version.outputs.package_version }} /bl:Meziantou.Analyzer.Pack.pack.binlog @@ -127,7 +129,7 @@ jobs: matrix: runs-on: [ ubuntu-latest ] configuration: [ Release ] - roslyn-version: [ 'roslyn3.8', 'roslyn4.2', 'roslyn4.4', 'roslyn4.6', 'roslyn4.8', 'default' ] + roslyn-version: [ 'roslyn3.8', 'roslyn4.2', 'roslyn4.4', 'roslyn4.6', 'roslyn4.8', 'roslyn4.14', 'default' ] fail-fast: false steps: - uses: actions/checkout@v4 diff --git a/Directory.Build.targets b/Directory.Build.targets index 45198f25..3561832b 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -72,6 +72,19 @@ + + + + + + + + $(DefineConstants);ROSLYN_4_8;ROSLYN_4_2_OR_GREATER;ROSLYN_4_4_OR_GREATER;ROSLYN_4_6_OR_GREATER;ROSLYN_4_8_OR_GREATER;ROSLYN_4_14_OR_GREATER + $(DefineConstants);CSHARP9_OR_GREATER;CSHARP10_OR_GREATER;CSHARP11_OR_GREATER;CSHARP12_OR_GREATER;CSHARP13_OR_GREATER + $(NoWarn);nullable + + + @@ -79,7 +92,7 @@ - $(DefineConstants);ROSLYN5_0;ROSLYN_4_2_OR_GREATER;ROSLYN_4_4_OR_GREATER;ROSLYN_4_5_OR_GREATER;ROSLYN_4_6_OR_GREATER;ROSLYN_4_8_OR_GREATER;ROSLYN_4_10_OR_GREATER;ROSLYN_5_0_OR_GREATER + $(DefineConstants);ROSLYN5_0;ROSLYN_4_2_OR_GREATER;ROSLYN_4_4_OR_GREATER;ROSLYN_4_5_OR_GREATER;ROSLYN_4_6_OR_GREATER;ROSLYN_4_8_OR_GREATER;ROSLYN_4_10_OR_GREATER;ROSLYN_4_14_OR_GREATER;ROSLYN_5_0_OR_GREATER $(DefineConstants);CSHARP9_OR_GREATER;CSHARP10_OR_GREATER;CSHARP11_OR_GREATER;CSHARP12_OR_GREATER;CSHARP13_OR_GREATER;CSHARP14_OR_GREATER $(NoWarn);CS0618 diff --git a/src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj b/src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj index 3bdaf071..11518327 100644 --- a/src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj +++ b/src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj @@ -37,5 +37,8 @@ + + + \ No newline at end of file diff --git a/src/Meziantou.Analyzer/Internals/CultureSensitiveFormattingContext.cs b/src/Meziantou.Analyzer/Internals/CultureSensitiveFormattingContext.cs index cdf10d05..8a8d6f1a 100755 --- a/src/Meziantou.Analyzer/Internals/CultureSensitiveFormattingContext.cs +++ b/src/Meziantou.Analyzer/Internals/CultureSensitiveFormattingContext.cs @@ -26,6 +26,8 @@ internal sealed class CultureSensitiveFormattingContext(Compilation compilation) public INamedTypeSymbol? SystemWindowsFontStretchSymbol { get; } = compilation.GetBestTypeByMetadataName("System.Windows.FontStretch"); public INamedTypeSymbol? SystemWindowsMediaBrushSymbol { get; } = compilation.GetBestTypeByMetadataName("System.Windows.Media.Brush"); public INamedTypeSymbol? NuGetVersioningSemanticVersionSymbol { get; } = compilation.GetBestTypeByMetadataName("NuGet.Versioning.SemanticVersion"); + public INamedTypeSymbol? FormattableStringSymbol { get; } = compilation.GetBestTypeByMetadataName("System.FormattableString"); + public INamedTypeSymbol? InterpolatedStringHandlerAttributeSymbol { get; } = compilation.GetBestTypeByMetadataName("System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute"); private static HashSet CreateExcludedMethods(Compilation compilation) { @@ -127,12 +129,22 @@ public bool IsCultureSensitiveOperation(IOperation operation, CultureSensitiveOp return initializer.ElementValues.Any(arg => IsCultureSensitiveOperation(arg.UnwrapImplicitConversionOperations(), options)); } +#if ROSLYN_4_14_OR_GREATER + else if (invocation.TargetMethod.Parameters.Length == 2 && invocation.Arguments[1].Value is ICollectionExpressionOperation collectionExpression) + { + return collectionExpression.Elements.Any(arg => IsCultureSensitiveOperation(arg.UnwrapImplicitConversionOperations(), options)); + } +#endif else { return invocation.Arguments.Skip(1).Any(arg => IsCultureSensitiveOperation(arg.Value.UnwrapImplicitConversionOperations(), options)); } } + // Check if all interpolated string arguments are culture-invariant + if (HasOnlyCultureInvariantInterpolatedStringArguments(invocation, options)) + return false; + if ((options & CultureSensitiveOptions.UseInvocationReturnType) == CultureSensitiveOptions.UseInvocationReturnType) return IsCultureSensitiveType(invocation.Type, options); @@ -175,6 +187,9 @@ public bool IsCultureSensitiveOperation(IOperation operation, CultureSensitiveOp } #endif + if (operation is IConversionOperation interpolatedConversion && interpolatedConversion.Type.IsEqualTo(FormattableStringSymbol)) + return IsCultureSensitiveOperation(interpolatedConversion.Operand, options); + if (operation is IInterpolatedStringOperation interpolatedString) { if (interpolatedString.Parts.Length == 0) @@ -482,4 +497,39 @@ private static bool IsConstantPositiveNumber(IOperation operation) return false; } + + private bool HasOnlyCultureInvariantInterpolatedStringArguments(IInvocationOperation invocation, CultureSensitiveOptions options) + { + var hasInterpolatedStringParam = false; + + foreach (var argument in invocation.Arguments) + { + var argumentType = argument.Value.Type; + if (argumentType is null) + continue; + + if (IsInterpolatedStringType(argumentType)) + { + hasInterpolatedStringParam = true; + + // If any interpolated string argument is culture-sensitive, return false + if (IsCultureSensitiveOperation(argument.Value, options)) + return false; + } + } + + // Return true only if we found interpolated string parameters and all were culture-invariant + return hasInterpolatedStringParam; + } + + private bool IsInterpolatedStringType(ITypeSymbol typeSymbol) + { + if (typeSymbol.IsEqualTo(FormattableStringSymbol)) + return true; + + if (InterpolatedStringHandlerAttributeSymbol is not null && typeSymbol.HasAttribute(InterpolatedStringHandlerAttributeSymbol)) + return true; + + return false; + } } diff --git a/src/Meziantou.Analyzer/Rules/UseIFormatProviderAnalyzer.cs b/src/Meziantou.Analyzer/Rules/UseIFormatProviderAnalyzer.cs index e17245c9..493d5e1c 100644 --- a/src/Meziantou.Analyzer/Rules/UseIFormatProviderAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/UseIFormatProviderAnalyzer.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Meziantou.Analyzer.Configurations; using Meziantou.Analyzer.Internals; using Microsoft.CodeAnalysis; diff --git a/tests/Meziantou.Analyzer.Test/Rules/UseIFormatProviderAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/UseIFormatProviderAnalyzerTests.cs index 752da8f3..a8900552 100755 --- a/tests/Meziantou.Analyzer.Test/Rules/UseIFormatProviderAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/UseIFormatProviderAnalyzerTests.cs @@ -11,6 +11,7 @@ private static ProjectBuilder CreateProjectBuilder() return new ProjectBuilder() .WithAnalyzer() .WithOutputKind(Microsoft.CodeAnalysis.OutputKind.ConsoleApplication) + .WithTargetFramework(TargetFramework.NetLatest) .AddMeziantouAttributes(); } @@ -314,6 +315,199 @@ class Location public override string ToString() => throw null; public string ToString(System.IFormatProvider formatProvider) => throw null; } +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + +#if CSHARP10_OR_GREATER + [Fact] + public async Task InterpolatedStringHandler_CultureSensitiveFormat_ShouldReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; + +[||]A.Print($"{DateTime.Now:D}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_CultureInvariantFormat_ShouldNotReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; + +A.Print($"{DateTime.Now:o}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_NoFormattableTypes_ShouldNotReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; + +A.Print($"XXX"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_MixedFormats_ShouldReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; + +[||]A.Print($"{DateTime.Now:o} | {DateTime.Now:D}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_CustomTypeWithAttribute_CultureInvariantFormat_ShouldNotReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; +using Meziantou.Analyzer.Annotations; + +A.Print($"{new Bar():o}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} + +[CultureInsensitiveType(format: "o")] +sealed class Bar : IFormattable +{ + public string ToString(string? format, IFormatProvider? formatProvider) => string.Empty; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_CustomTypeWithAttribute_CultureSensitiveFormat_ShouldReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; +using Meziantou.Analyzer.Annotations; + +[||]A.Print($"{new Bar():D}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; + public static void Print(IFormatProvider provider, ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} + +[CultureInsensitiveType(format: "o")] +sealed class Bar : IFormattable +{ + public string ToString(string? format, IFormatProvider? formatProvider) => string.Empty; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringHandler_NoOverload_ShouldNotReport() + { + var sourceCode = """ +using System; +using System.Runtime.CompilerServices; + +A.Print($"{DateTime.Now:D}"); + +class A +{ + public static void Print(ref DefaultInterpolatedStringHandler interpolatedStringHandler) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } +#endif + + [Fact] + public async Task FormattableString_CultureSensitiveFormat_ShouldReport() + { + var sourceCode = """ +using System; + +[||]A.Sample($"{DateTime.Now:D}"); + +class A +{ + public static void Sample(FormattableString value) => throw null; + public static void Sample(IFormatProvider format, FormattableString value) => throw null; +} +"""; + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task FormattableString_CultureInvariantFormat_ShouldNotReport() + { + var sourceCode = """ +using System; + +A.Sample($"{DateTime.Now:o}"); + +class A +{ + public static void Sample(FormattableString value) => throw null; + public static void Sample(IFormatProvider format, FormattableString value) => throw null; +} """; await CreateProjectBuilder() .WithSourceCode(sourceCode)