diff --git a/README.md b/README.md index a0a3c87b..5b6ff092 100755 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0182](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0182.md)|Design|Avoid unused internal types|ℹ️|✔️|✔️| |[MA0183](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0183.md)|Usage|string.Format should use a format string with placeholders|⚠️|✔️|❌| |[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|ℹ️|❌|✔️| +|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️| diff --git a/docs/README.md b/docs/README.md index db87f4f8..2dbecba3 100755 --- a/docs/README.md +++ b/docs/README.md @@ -183,6 +183,7 @@ |[MA0182](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0182.md)|Design|Avoid unused internal types|ℹ️|✔️|✔️| |[MA0183](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0183.md)|Usage|string.Format should use a format string with placeholders|⚠️|✔️|❌| |[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|ℹ️|❌|✔️| +|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -747,6 +748,9 @@ dotnet_diagnostic.MA0183.severity = warning # MA0184: Do not use interpolated string without parameters dotnet_diagnostic.MA0184.severity = none + +# MA0185: Simplify string.Create when all parameters are culture invariant +dotnet_diagnostic.MA0185.severity = suggestion ``` # .editorconfig - all rules disabled @@ -1297,4 +1301,7 @@ dotnet_diagnostic.MA0183.severity = none # MA0184: Do not use interpolated string without parameters dotnet_diagnostic.MA0184.severity = none + +# MA0185: Simplify string.Create when all parameters are culture invariant +dotnet_diagnostic.MA0185.severity = none ``` diff --git a/docs/Rules/MA0185.md b/docs/Rules/MA0185.md new file mode 100644 index 00000000..b25ca178 --- /dev/null +++ b/docs/Rules/MA0185.md @@ -0,0 +1,28 @@ +# MA0185 - Simplify string.Create when all parameters are culture invariant + +Sources: [SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer.cs), [SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer.cs) + + +When using `string.Create(CultureInfo.InvariantCulture, ...)` with an interpolated string where all parameters are culture-invariant, you can simplify the code by using a simple interpolated string instead. + +````c# +// ❌ Unnecessary use of string.Create with culture-invariant parameters +var x = string.Create(CultureInfo.InvariantCulture, $"Current time is {DateTime.Now:O}."); + +// ✅ Simplified version +var y = $"Current time is {DateTime.Now:O}."; +```` + +The analyzer detects when all interpolated values are culture-invariant, such as: +- Strings +- GUIDs +- DateTime with invariant formats (O, o, R, r, s, u) +- TimeSpan with invariant formats (c, t, T) +- Unsigned integers +- Enum values +- Boolean values +- and other culture-insensitive types + +The analyzer will NOT suggest simplification when: +- Any parameter is culture-sensitive (e.g., `double`, `DateTime` with culture-sensitive format) +- The culture is not `CultureInfo.InvariantCulture` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer.cs new file mode 100644 index 00000000..1b2337be --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer.cs @@ -0,0 +1,65 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Operations; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class SimplifyStringCreateWhenAllParametersAreCultureInvariantFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.SimplifyStringCreateWhenAllParametersAreCultureInvariant); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root?.FindNode(context.Span, getInnermostNodeForTie: true) is not InvocationExpressionSyntax nodeToFix) + return; + + var title = "Simplify to interpolated string"; + var codeAction = CodeAction.Create( + title, + ct => Fix(context.Document, nodeToFix, ct), + equivalenceKey: title); + + context.RegisterCodeFix(codeAction, context.Diagnostics); + } + + private static async Task Fix(Document document, InvocationExpressionSyntax nodeToFix, CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + if (editor.SemanticModel.GetOperation(nodeToFix, cancellationToken) is not IInvocationOperation op) + return document; + + // Get the interpolated string from the second argument + if (op.Arguments.Length != 2) + return document; + + var interpolatedStringArgument = op.Arguments[1]; + +#if CSHARP10_OR_GREATER + if (interpolatedStringArgument.Value is IInterpolatedStringHandlerCreationOperation handlerCreation) + { + // The Content property contains the interpolated string operation + if (handlerCreation.Content is IInterpolatedStringOperation interpolatedString) + { + // Get the syntax of the interpolated string + var interpolatedStringSyntax = interpolatedString.Syntax as InterpolatedStringExpressionSyntax; + if (interpolatedStringSyntax is not null) + { + editor.ReplaceNode(nodeToFix, interpolatedStringSyntax); + } + } + } +#endif + + return editor.GetChangedDocument(); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index 93c78851..0649345a 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -547,3 +547,6 @@ dotnet_diagnostic.MA0183.severity = warning # MA0184: Do not use interpolated string without parameters dotnet_diagnostic.MA0184.severity = none + +# MA0185: Simplify string.Create when all parameters are culture invariant +dotnet_diagnostic.MA0185.severity = suggestion diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 7508a853..bb651f75 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -547,3 +547,6 @@ dotnet_diagnostic.MA0183.severity = none # MA0184: Do not use interpolated string without parameters dotnet_diagnostic.MA0184.severity = none + +# MA0185: Simplify string.Create when all parameters are culture invariant +dotnet_diagnostic.MA0185.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 8c7e3b08..22d72ced 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -184,6 +184,7 @@ internal static class RuleIdentifiers public const string AvoidUnusedInternalTypes = "MA0182"; public const string StringFormatShouldBeConstant = "MA0183"; public const string DoNotUseInterpolatedStringWithoutParameters = "MA0184"; + public const string SimplifyStringCreateWhenAllParametersAreCultureInvariant = "MA0185"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer.cs b/src/Meziantou.Analyzer/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer.cs new file mode 100644 index 00000000..15ee26ee --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer.cs @@ -0,0 +1,97 @@ +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 SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.SimplifyStringCreateWhenAllParametersAreCultureInvariant, + title: "Simplify string.Create when all parameters are culture invariant", + messageFormat: "Simplify string.Create when all parameters are culture invariant", + RuleCategories.Performance, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.SimplifyStringCreateWhenAllParametersAreCultureInvariant)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(ctx => + { + if (!ctx.Compilation.GetCSharpLanguageVersion().IsCSharp10OrAbove()) + return; + + var formatProviderSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.IFormatProvider"); + var cultureInfoSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.Globalization.CultureInfo"); + var defaultInterpolatedStringHandlerSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.Runtime.CompilerServices.DefaultInterpolatedStringHandler"); + + var stringCreateSymbol = ctx.Compilation.GetSpecialType(SpecialType.System_String) + .GetMembers("Create") + .OfType() + .FirstOrDefault(m => m.ReturnType.IsString() && m.Parameters.Length == 2 && m.Parameters[0].Type.IsEqualTo(formatProviderSymbol) && m.Parameters[1].Type.IsEqualTo(defaultInterpolatedStringHandlerSymbol)); + + if (stringCreateSymbol is null || cultureInfoSymbol is null) + return; + + var cultureInfoInvariantCultureProperty = cultureInfoSymbol.GetMembers("InvariantCulture").OfType().FirstOrDefault(); + if (cultureInfoInvariantCultureProperty is null) + return; + + var cultureSensitiveContext = new CultureSensitiveFormattingContext(ctx.Compilation); + + ctx.RegisterOperationAction(context => AnalyzeInvocation(context, stringCreateSymbol, cultureInfoInvariantCultureProperty, cultureSensitiveContext), OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, IMethodSymbol stringCreateSymbol, IPropertySymbol cultureInfoInvariantCultureProperty, CultureSensitiveFormattingContext cultureSensitiveContext) + { + var operation = (IInvocationOperation)context.Operation; + + if (!operation.TargetMethod.IsEqualTo(stringCreateSymbol)) + return; + + // Check if the first argument is CultureInfo.InvariantCulture + if (operation.Arguments.Length != 2) + return; + + var cultureArgument = operation.Arguments[0].Value; + if (!IsCultureInfoInvariantCulture(cultureArgument, cultureInfoInvariantCultureProperty)) + return; + + // Check if the second argument (interpolated string handler) has only culture-invariant parameters + var interpolatedStringArgument = operation.Arguments[1].Value; + +#if CSHARP10_OR_GREATER + if (interpolatedStringArgument is IInterpolatedStringHandlerCreationOperation handlerCreation) + { + var interpolatedStringContent = handlerCreation.Content; + if (!cultureSensitiveContext.IsCultureSensitiveOperation(interpolatedStringContent, CultureSensitiveOptions.None)) + { + context.ReportDiagnostic(Rule, operation); + } + } +#endif + } + + private static bool IsCultureInfoInvariantCulture(IOperation operation, IPropertySymbol cultureInfoInvariantCultureProperty) + { + operation = operation.UnwrapImplicitConversionOperations(); + + if (operation is IPropertyReferenceOperation propertyReference) + { + return SymbolEqualityComparer.Default.Equals(propertyReference.Property, cultureInfoInvariantCultureProperty); + } + + return false; + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzerTests.cs new file mode 100644 index 00000000..94ff1265 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzerTests.cs @@ -0,0 +1,294 @@ +using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class SimplifyStringCreateWhenAllParametersAreCultureInvariantAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider() + .WithTargetFramework(TargetFramework.Net6_0); + } + +#if CSHARP10_OR_GREATER + [Fact] + public async Task StringCreateWithInvariantCulture_OnlyCultureInvariantParameters_ShouldReport() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = [|string.Create(CultureInfo.InvariantCulture, $"Current time is {DateTime.Now:O}.")|]; + } +} +"""; + + const string Fix = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = $"Current time is {DateTime.Now:O}."; + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(Fix) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithString_ShouldReport() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var name = "test"; + var x = [|string.Create(CultureInfo.InvariantCulture, $"Name: {name}")|]; + } +} +"""; + + const string Fix = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var name = "test"; + var x = $"Name: {name}"; + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(Fix) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithGuid_ShouldReport() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var id = Guid.NewGuid(); + var x = [|string.Create(CultureInfo.InvariantCulture, $"ID: {id}")|]; + } +} +"""; + + const string Fix = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var id = Guid.NewGuid(); + var x = $"ID: {id}"; + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(Fix) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithTimeSpanInvariantFormat_ShouldReport() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var duration = TimeSpan.FromSeconds(42); + var x = [|string.Create(CultureInfo.InvariantCulture, $"Duration: {duration:c}")|]; + } +} +"""; + + const string Fix = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var duration = TimeSpan.FromSeconds(42); + var x = $"Duration: {duration:c}"; + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(Fix) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithCultureSensitiveParameter_NoDiagnostic() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var price = 42.5; + var x = string.Create(CultureInfo.InvariantCulture, $"Price: {price}"); + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithDateTimeNonInvariantFormat_NoDiagnostic() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = string.Create(CultureInfo.InvariantCulture, $"Current time is {DateTime.Now:d}."); + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithCurrentCulture_NoDiagnostic() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = string.Create(CultureInfo.CurrentCulture, $"Current time is {DateTime.Now:O}."); + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_LiteralOnly_ShouldReport() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = [|string.Create(CultureInfo.InvariantCulture, $"Hello World")|]; + } +} +"""; + + const string Fix = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var x = $"Hello World"; + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(Fix) + .ValidateAsync(); + } + + [Fact] + public async Task StringCreateWithInvariantCulture_WithInteger_NoDiagnostic() + { + const string SourceCode = """ +using System; +using System.Globalization; + +class TypeName +{ + public void Test() + { + var count = 42; + var x = string.Create(CultureInfo.InvariantCulture, $"Count: {count}"); + } +} +"""; + await CreateProjectBuilder() + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp10) + .WithTargetFramework(TargetFramework.Net6_0) + .WithSourceCode(SourceCode) + .ValidateAsync(); + } +#endif +}