-
Notifications
You must be signed in to change notification settings - Fork 14
feat: Add analyzer to prefer interpolated strings over string.Format with comprehensive edge case handling (disabled by default) #840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
002584d
4e72613
bc5adfb
dfd325a
a35ecbc
c2d1cd6
be6d1b3
402fdf4
dabcfd4
5429c27
8197765
e3f6629
5ec20f1
1dcd55b
f9897f2
badb218
5f2b714
bc159f9
b274f33
5362bae
6b32b79
bd1bd13
f3ec3ca
cd7cf12
f2adad9
33bf07c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # PH2148: Prefer interpolated strings over string.Format | ||
|
|
||
| | Property | Value | | ||
| |--|--| | ||
| | Package | [Philips.CodeAnalysis.MaintainabilityAnalyzers](https://www.nuget.org/packages/Philips.CodeAnalysis.MaintainabilityAnalyzers) | | ||
| | Diagnostic ID | PH2148 | | ||
| | Category | [Readability](../Readability.md) | | ||
| | Analyzer | [PreferInterpolatedStringAnalyzer](https://github.com/philips-software/roslyn-analyzers/blob/main/Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs) | ||
| | CodeFix | No | | ||
| | Severity | Error | | ||
| | Enabled By Default | No | | ||
|
|
||
| ## Introduction | ||
|
|
||
| Prefer interpolated strings over `string.Format` calls with simple placeholders for better readability and reduced error-proneness. | ||
|
|
||
| ## How to solve | ||
|
|
||
| Replace `string.Format` calls that use simple placeholders like `{0}`, `{1}`, etc. with interpolated strings. | ||
|
|
||
| ## Example | ||
|
|
||
| Code that triggers a diagnostic: | ||
| ``` cs | ||
| class BadExample | ||
| { | ||
| public void BadMethod() | ||
| { | ||
| int num = 42; | ||
| string name = "World"; | ||
|
|
||
| // Case 1: Interpolated string preferred | ||
| string str1 = string.Format("Hello {0}, this is number {1}", name, num); | ||
|
|
||
| // Case 2: Unnecessary string.Format call | ||
| string str2 = string.Format("Simple string with no placeholders"); | ||
| } | ||
| } | ||
|
|
||
| ``` | ||
|
|
||
| And the replacement code: | ||
| ``` cs | ||
| class GoodExample | ||
| { | ||
| public void GoodMethod() | ||
| { | ||
| int num = 42; | ||
| string name = "World"; | ||
|
|
||
| // Case 1: Use interpolated string | ||
| string str1 = $"Hello {name}, this is number {num}"; | ||
|
|
||
| // Case 2: Use simple string literal | ||
| string str2 = "Simple string with no placeholders"; | ||
| } | ||
| } | ||
|
|
||
| ``` | ||
|
|
||
| ## What it ignores | ||
|
|
||
| The analyzer is conservative and only suggests conversion in safe cases: | ||
|
|
||
| - **Format specifiers**: `string.Format("Value: {0:N2}", value)` - requires more complex conversion logic | ||
| - **Non-literal format strings**: `string.Format(formatVariable, args)` - can't analyze statically | ||
|
|
||
| ## Configuration | ||
|
|
||
| This analyzer does not offer any special configuration. The general ways of [suppressing](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/suppress-warnings) diagnostics apply. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| // © 2019 Koninklijke Philips N.V. See License.md in the project root for license information. | ||
|
|
||
| using System; | ||
| using System.Collections.Immutable; | ||
| using System.Globalization; | ||
| using Microsoft.CodeAnalysis; | ||
| using Microsoft.CodeAnalysis.Diagnostics; | ||
| using Microsoft.CodeAnalysis.Operations; | ||
| using Philips.CodeAnalysis.Common; | ||
|
|
||
| namespace Philips.CodeAnalysis.MaintainabilityAnalyzers.Readability | ||
| { | ||
| [DiagnosticAnalyzer(LanguageNames.CSharp)] | ||
| public class PreferInterpolatedStringAnalyzer : DiagnosticAnalyzerBase | ||
| { | ||
| [Flags] | ||
| private enum ConversionResult | ||
|
Check warning on line 17 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs
|
||
| { | ||
| None = 0, | ||
| CanConvert = 1, | ||
| IsUnnecessary = 2 | ||
| } | ||
| private const string Title = @"Prefer interpolated strings over string.Format"; | ||
| private const string MessageFormat = @"Replace string.Format with interpolated string for better readability"; | ||
| private const string Description = @"Interpolated strings are more readable and less error prone than string.Format"; | ||
|
|
||
| private const string UnnecessaryTitle = @"Unnecessary call to string.Format"; | ||
| private const string UnnecessaryMessageFormat = @"Remove unnecessary call to string.Format"; | ||
| private const string UnnecessaryDescription = @"string.Format calls with no placeholders are unnecessary"; | ||
|
|
||
| private const string Category = Categories.Readability; | ||
|
|
||
| private static readonly DiagnosticDescriptor Rule = new( | ||
| DiagnosticId.PreferInterpolatedString.ToId(), | ||
| Title, | ||
| MessageFormat, | ||
| Category, | ||
| DiagnosticSeverity.Error, | ||
| isEnabledByDefault: false, | ||
| description: Description); | ||
|
|
||
| private static readonly DiagnosticDescriptor UnnecessaryRule = new( | ||
| DiagnosticId.PreferInterpolatedString.ToId(), | ||
| UnnecessaryTitle, | ||
| UnnecessaryMessageFormat, | ||
| Category, | ||
| DiagnosticSeverity.Error, | ||
| isEnabledByDefault: false, | ||
| description: UnnecessaryDescription); | ||
|
|
||
| public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule, UnnecessaryRule); | ||
|
|
||
| protected override void InitializeCompilation(CompilationStartAnalysisContext context) | ||
| { | ||
| context.RegisterOperationAction(OnInvocation, OperationKind.Invocation); | ||
| } | ||
|
|
||
| private void OnInvocation(OperationAnalysisContext operationContext) | ||
| { | ||
| var invocation = (IInvocationOperation)operationContext.Operation; | ||
|
|
||
| if (!IsStringFormatMethod(invocation.TargetMethod)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| ConversionResult result = CanConvertToInterpolatedString(invocation); | ||
| if (result == ConversionResult.None) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| Location location = invocation.Syntax.GetLocation(); | ||
| if (result.HasFlag(ConversionResult.IsUnnecessary)) | ||
| { | ||
| operationContext.ReportDiagnostic(Diagnostic.Create(UnnecessaryRule, location)); | ||
| } | ||
| else | ||
| { | ||
| operationContext.ReportDiagnostic(Diagnostic.Create(Rule, location)); | ||
| } | ||
| } | ||
|
|
||
| private bool IsStringFormatMethod(IMethodSymbol targetMethod) | ||
| { | ||
| return targetMethod.Name == "Format" && | ||
| targetMethod.ContainingType != null && | ||
| targetMethod.ContainingType.SpecialType == SpecialType.System_String; | ||
| } | ||
|
|
||
| private ConversionResult CanConvertToInterpolatedString(IInvocationOperation invocation) | ||
| { | ||
| if (invocation.Arguments.Length < 1) | ||
| { | ||
| return ConversionResult.None; | ||
| } | ||
|
|
||
| // Check if this is an IFormatProvider overload (first parameter is IFormatProvider) | ||
| if (invocation.TargetMethod.Parameters.Length > 0 && | ||
| invocation.TargetMethod.Parameters[0].Type?.Name == "IFormatProvider") | ||
| { | ||
| return ConversionResult.None; | ||
| } | ||
|
|
||
| IOperation formatStringArgument = invocation.Arguments[0].Value; | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle string.Format($"blah blah blah"). (unit test)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in commit f2adad9. The analyzer now detects when the format string itself is an interpolated string (e.g., |
||
| // Check if format string is an interpolated string | ||
| if (formatStringArgument.Kind == OperationKind.InterpolatedString) | ||
| { | ||
| return ConversionResult.None; | ||
| } | ||
|
|
||
| if (formatStringArgument.Kind != OperationKind.Literal || | ||
| formatStringArgument.Type?.SpecialType != SpecialType.System_String) | ||
| { | ||
| return ConversionResult.None; | ||
| } | ||
|
|
||
| var formatString = (string)formatStringArgument.ConstantValue.Value; | ||
|
|
||
| // Parse the format string to find actual placeholders, ignoring escaped braces | ||
| var placeholderCount = CountFormatPlaceholders(formatString); | ||
|
|
||
| if (placeholderCount == 0) | ||
| { | ||
| // Only flag as unnecessary if there are no braces at all | ||
| // If there are escaped braces, string.Format is needed to produce literal braces | ||
| var hasAnyBraces = formatString.Contains("{") || formatString.Contains("}"); | ||
| if (!hasAnyBraces) | ||
| { | ||
| return ConversionResult.CanConvert | ConversionResult.IsUnnecessary; | ||
| } | ||
|
|
||
| // Has escaped braces but no placeholders - don't suggest conversion | ||
| return ConversionResult.None; | ||
| } | ||
|
|
||
| return invocation.Arguments.Length > 1 ? ConversionResult.CanConvert : ConversionResult.None; | ||
| } | ||
|
|
||
| private int CountFormatPlaceholders(string formatString) | ||
|
Check warning on line 141 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs
|
||
| { | ||
| var placeholderCount = 0; | ||
| var i = 0; | ||
|
|
||
| while (i < formatString.Length) | ||
| { | ||
| if (formatString[i] == '{') | ||
| { | ||
| if (i + 1 < formatString.Length && formatString[i + 1] == '{') | ||
| { | ||
| // Escaped brace {{, skip both characters | ||
| i += 2; | ||
| continue; | ||
| } | ||
|
|
||
| // Look for closing brace | ||
| var j = i + 1; | ||
| while (j < formatString.Length && formatString[j] != '}') | ||
| { | ||
| j++; | ||
| } | ||
|
|
||
| if (j < formatString.Length && formatString[j] == '}') | ||
| { | ||
| // Found valid placeholder, check if it's a simple numeric placeholder | ||
| var content = formatString.Substring(i + 1, j - i - 1); | ||
|
|
||
| // Handle alignment options like {0,3} or {0,3:N2} by splitting on ',' | ||
| if (content.Contains(",")) | ||
| { | ||
| // Has alignment component - bail out as interpolated strings don't support this syntax directly | ||
| return 0; | ||
| } | ||
|
|
||
| // Handle format specifiers like {0:N2} by splitting on ':' | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also handle alignment options (denoted with a comma). https://learn.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2008/txafckwd(v=vs.90)#alignment-component In such cases, it's OK to just abort the operation, though currently we'll try to TryParse "0,3" for example. (Unit test)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in commit f2adad9. The analyzer now detects alignment options like |
||
| var colonIndex = content.IndexOf(':'); | ||
| var indexPart = colonIndex >= 0 ? content.Substring(0, colonIndex) : content; | ||
|
|
||
| if (int.TryParse(indexPart.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) | ||
| { | ||
| placeholderCount++; | ||
| } | ||
| i = j + 1; | ||
| } | ||
| else | ||
| { | ||
| i++; | ||
| } | ||
| } | ||
| else if (formatString[i] == '}') | ||
| { | ||
| if (i + 1 < formatString.Length && formatString[i + 1] == '}') | ||
| { | ||
| // Escaped brace }}, skip both characters | ||
| i += 2; | ||
| continue; | ||
| } | ||
| i++; | ||
| } | ||
| else | ||
| { | ||
| i++; | ||
| } | ||
| } | ||
|
|
||
| return placeholderCount; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if string.Format(IFormatPrivder, ...) bail out gracefully (unit test)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in commit f2adad9. The analyzer now detects IFormatProvider overloads (e.g.,
string.Format(CultureInfo.InvariantCulture, "Value: {0}", value)) by checking if the first parameter is of type IFormatProvider and gracefully bails out. Added unit testNoWarningForIFormatProviderOverloadto verify this behavior.