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
+}