diff --git a/README.md b/README.md index 579fd641..a0a3c87b 100755 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0181](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0181.md)|Style|Do not use cast|ℹ️|❌|❌| |[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|ℹ️|❌|✔️| diff --git a/docs/README.md b/docs/README.md index 1e7f3694..db87f4f8 100755 --- a/docs/README.md +++ b/docs/README.md @@ -182,6 +182,7 @@ |[MA0181](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0181.md)|Style|Do not use cast|ℹ️|❌|❌| |[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|ℹ️|❌|✔️| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -743,6 +744,9 @@ dotnet_diagnostic.MA0182.severity = suggestion # MA0183: string.Format should use a format string with placeholders dotnet_diagnostic.MA0183.severity = warning + +# MA0184: Do not use interpolated string without parameters +dotnet_diagnostic.MA0184.severity = none ``` # .editorconfig - all rules disabled @@ -1290,4 +1294,7 @@ dotnet_diagnostic.MA0182.severity = none # MA0183: string.Format should use a format string with placeholders dotnet_diagnostic.MA0183.severity = none + +# MA0184: Do not use interpolated string without parameters +dotnet_diagnostic.MA0184.severity = none ``` diff --git a/docs/Rules/MA0184.md b/docs/Rules/MA0184.md new file mode 100644 index 00000000..c718994c --- /dev/null +++ b/docs/Rules/MA0184.md @@ -0,0 +1,43 @@ +# MA0184 - Do not use interpolated string without parameters + +Sources: [DoNotUseInterpolatedStringWithoutParametersAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzer.cs), [DoNotUseInterpolatedStringWithoutParametersFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/DoNotUseInterpolatedStringWithoutParametersFixer.cs) + + +## Description + +An interpolated string without any parameters is unnecessary and can be converted to a regular string literal for better clarity and consistency. + +## Example + +```csharp +// ❌ Bad: Using interpolated string without parameters +var message = $"Required attribute 'output' not found."; + +// ✅ Good: Use a regular string literal +var message = "Required attribute 'output' not found."; +``` + +## Exceptions + +This rule does not report diagnostics when: + +1. The interpolated string is assigned to or converted to `FormattableString`: + ```csharp + FormattableString fs = $"Hello"; // OK + ``` + +2. The interpolated string is used with a custom `InterpolatedStringHandler`: + ```csharp + void Method(CustomInterpolatedStringHandler handler) { } + Method($"Hello"); // OK if CustomInterpolatedStringHandler has InterpolatedStringHandlerAttribute + ``` + +## Configuration + +This rule is **disabled by default** as it's a stylistic preference that doesn't affect runtime behavior. + +To enable it, add the following to your `.editorconfig`: + +```ini +dotnet_diagnostic.MA0184.severity = suggestion +``` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/DoNotUseInterpolatedStringWithoutParametersFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/DoNotUseInterpolatedStringWithoutParametersFixer.cs new file mode 100644 index 00000000..0298d5d2 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/DoNotUseInterpolatedStringWithoutParametersFixer.cs @@ -0,0 +1,88 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class DoNotUseInterpolatedStringWithoutParametersFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(RuleIdentifiers.DoNotUseInterpolatedStringWithoutParameters); + + 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 not InterpolatedStringExpressionSyntax interpolatedString) + return; + + context.RegisterCodeFix( + CodeAction.Create( + "Convert to regular string", + ct => ConvertToRegularString(context.Document, interpolatedString, ct), + equivalenceKey: "Convert to regular string"), + context.Diagnostics); + } + + private static async Task ConvertToRegularString(Document document, InterpolatedStringExpressionSyntax interpolatedString, CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + // Check if this is a raw string literal (C# 11+) +#if CSHARP10_OR_GREATER + var isRawString = interpolatedString.StringStartToken.IsKind(SyntaxKind.InterpolatedMultiLineRawStringStartToken) || + interpolatedString.StringStartToken.IsKind(SyntaxKind.InterpolatedSingleLineRawStringStartToken); +#else + var isRawString = false; +#endif + + if (isRawString) + { + // For raw strings, simply remove the $ prefix from the start token + // $""" text """ -> """ text """ + var originalText = interpolatedString.ToFullString(); + + // Find the position of $ in the start token and remove it + var dollarIndex = originalText.IndexOf('$', StringComparison.Ordinal); + if (dollarIndex >= 0) + { + var newText = originalText.Remove(dollarIndex, 1); + var newNode = SyntaxFactory.ParseExpression(newText); + + editor.ReplaceNode(interpolatedString, newNode.WithTriviaFrom(interpolatedString)); + } + } + else + { + // Extract the string content from the interpolated string + var stringContent = string.Empty; + foreach (var content in interpolatedString.Contents) + { + if (content is InterpolatedStringTextSyntax textSyntax) + { + // Use the ValueText which contains the actual string value (not escaped) + stringContent += textSyntax.TextToken.ValueText; + } + } + + // Create a regular string literal with the same content + var regularString = SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(stringContent)); + + editor.ReplaceNode(interpolatedString, regularString.WithTriviaFrom(interpolatedString)); + } + + return editor.GetChangedDocument(); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index f2d7d450..93c78851 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -544,3 +544,6 @@ dotnet_diagnostic.MA0182.severity = suggestion # MA0183: string.Format should use a format string with placeholders dotnet_diagnostic.MA0183.severity = warning + +# MA0184: Do not use interpolated string without parameters +dotnet_diagnostic.MA0184.severity = none diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 4e163f43..7508a853 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -544,3 +544,6 @@ dotnet_diagnostic.MA0182.severity = none # MA0183: string.Format should use a format string with placeholders dotnet_diagnostic.MA0183.severity = none + +# MA0184: Do not use interpolated string without parameters +dotnet_diagnostic.MA0184.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 9e7f3f73..8c7e3b08 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -183,6 +183,7 @@ internal static class RuleIdentifiers public const string DoNotUseCast = "MA0181"; public const string AvoidUnusedInternalTypes = "MA0182"; public const string StringFormatShouldBeConstant = "MA0183"; + public const string DoNotUseInterpolatedStringWithoutParameters = "MA0184"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzer.cs new file mode 100644 index 00000000..835d72f1 --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzer.cs @@ -0,0 +1,90 @@ +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 DoNotUseInterpolatedStringWithoutParametersAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.DoNotUseInterpolatedStringWithoutParameters, + title: "Do not use interpolated string without parameters", + messageFormat: "Do not use interpolated string without parameters", + RuleCategories.Style, + DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.DoNotUseInterpolatedStringWithoutParameters)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(ctx => + { + var formattableStringSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.FormattableString"); + var interpolatedStringHandlerAttributeSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute"); + + ctx.RegisterOperationAction(context => AnalyzeInterpolatedString(context, formattableStringSymbol, interpolatedStringHandlerAttributeSymbol), OperationKind.InterpolatedString); + }); + } + + private static void AnalyzeInterpolatedString(OperationAnalysisContext context, INamedTypeSymbol? formattableStringSymbol, INamedTypeSymbol? interpolatedStringHandlerAttributeSymbol) + { + var operation = (IInterpolatedStringOperation)context.Operation; + + // Only report if there are no interpolations (no parameters) + if (operation.Parts.Any(part => part is IInterpolationOperation)) + return; + +#if CSHARP10_OR_GREATER + // If there are IInterpolatedStringAppendOperation parts, it means a custom handler is being used + if (operation.Parts.Any(part => part is IInterpolatedStringAppendOperation)) + return; +#endif + + // Check if the target type is FormattableString + var parent = operation.Parent; + if (parent is IConversionOperation conversionOperation) + { + // If converting to FormattableString, don't report + if (conversionOperation.Type?.IsEqualTo(formattableStringSymbol) == true) + return; + + // If converting to a custom InterpolatedStringHandler, don't report + if (IsInterpolatedStringHandler(conversionOperation.Type, interpolatedStringHandlerAttributeSymbol)) + return; + } + + // If assigned to FormattableString, don't report + if (parent is IVariableInitializerOperation variableInitializer) + { + if (variableInitializer.Parent is IVariableDeclaratorOperation declarator) + { + if (declarator.Symbol?.Type?.IsEqualTo(formattableStringSymbol) == true) + return; + + if (IsInterpolatedStringHandler(declarator.Symbol?.Type, interpolatedStringHandlerAttributeSymbol)) + return; + } + } + + // Report diagnostic as a suggestion (Hidden severity with unnecessary tag) + var diagnostic = Diagnostic.Create(Rule, operation.Syntax.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsInterpolatedStringHandler(ITypeSymbol? typeSymbol, INamedTypeSymbol? interpolatedStringHandlerAttributeSymbol) + { + if (typeSymbol is null || interpolatedStringHandlerAttributeSymbol is null) + return false; + + return typeSymbol.HasAttribute(interpolatedStringHandlerAttributeSymbol); + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzerTests.cs new file mode 100644 index 00000000..44634bf1 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseInterpolatedStringWithoutParametersAnalyzerTests.cs @@ -0,0 +1,295 @@ +using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class DoNotUseInterpolatedStringWithoutParametersAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider(); + } + + [Fact] + public async Task InterpolatedStringWithoutParameters_ShouldReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var x = [|$"Required attribute 'output' not found."|]; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task RegularString_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var x = "Required attribute 'output' not found."; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringWithParameters_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var name = "output"; + var x = $"Required attribute '{name}' not found."; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringWithoutParameters_AssignedToFormattableString_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +using System; + +class TypeName +{ + public void Test() + { + FormattableString x = $"Required attribute 'output' not found."; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringWithoutParameters_ConvertedToFormattableString_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +using System; + +class TypeName +{ + public void Test(FormattableString fs) + { + } + + public void Run() + { + Test($"Required attribute 'output' not found."); + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + +#if CSHARP10_OR_GREATER + [Fact] + public async Task InterpolatedStringWithoutParameters_CustomInterpolatedStringHandler_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test(CustomInterpolatedStringHandler handler) + { + } + + public void Run() + { + Test($"Required attribute 'output' not found."); + } +} + +[System.Runtime.CompilerServices.InterpolatedStringHandler] +public struct CustomInterpolatedStringHandler +{ + public CustomInterpolatedStringHandler(int literalLength, int formattedCount) + { + } + + public void AppendLiteral(string s) + { + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .WithTargetFramework(TargetFramework.Net6_0) + .ValidateAsync(); + } +#endif + + [Fact] + public async Task InterpolatedStringWithoutParameters_InReturnStatement_ShouldReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public string Test() + { + return [|$"Required attribute 'output' not found."|]; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringWithoutParameters_InMethodArgument_ShouldReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test(string message) + { + } + + public void Run() + { + Test([|$"Required attribute 'output' not found."|]); + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task InterpolatedStringWithEmptyInterpolation_ShouldNotReportDiagnostic() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var name = "test"; + var x = $"Value: {name}"; + } +} +"""; + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task CodeFix_ShouldConvertToRegularString() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var x = [|$"Required attribute 'output' not found."|]; + } +} +"""; + + const string FixedCode = """ +class TypeName +{ + public void Test() + { + var x = "Required attribute 'output' not found."; + } +} +"""; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(FixedCode) + .ValidateAsync(); + } + + [Fact] + public async Task CodeFix_ShouldHandleEscapedCharacters() + { + const string SourceCode = """ +class TypeName +{ + public void Test() + { + var x = [|$"Line 1\nLine 2"|]; + } +} +"""; + + const string FixedCode = """ +class TypeName +{ + public void Test() + { + var x = "Line 1\nLine 2"; + } +} +"""; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .ShouldFixCodeWith(FixedCode) + .ValidateAsync(); + } + +#if CSHARP11_OR_GREATER + [Fact] + public async Task RawInterpolatedStringWithoutParameters_ShouldReportDiagnostic() + { + const string SourceCode = """" +class TypeName +{ + public void Test() + { + _ = [|$""" + Sample + """|]; + } +} +""""; + + const string FixedCode = """" +class TypeName +{ + public void Test() + { + _ = """ + Sample + """; + } +} +""""; + + await CreateProjectBuilder() + .WithSourceCode(SourceCode) + .WithLanguageVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp11) + .ShouldFixCodeWith(FixedCode) + .ValidateAsync(); + } +#endif +}