Skip to content

Commit dc194bb

Browse files
authored
Add Roslyn Analyzer for Replacing string.Format with Interpolated Strings (ECS0004) (#41)
This pull request introduces a new Roslyn analyzer and code fix to enforce the use of interpolated strings instead of `string.Format`. This change addresses the recommendations outlined in Effective C#: 50 Specific Ways to Improve your C#. ## Rule Details ### Rule ID: ECS0004 **Title:** Replace string.Format with interpolated string **Category:** Style **Description:** This rule identifies instances of `string.Format` and suggests replacing them with C# interpolated strings for improved readability and reduced error potential.
1 parent 5da647e commit dc194bb

13 files changed

+490
-7
lines changed

Diff for: docs/rules/ECS0002.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# PRC001: Prefer readonly over const
1+
# ECS0002: Prefer readonly over const
22

33
## Cause
44
Using `const` fields for constants that do not need to be available at compile time.

Diff for: docs/rules/ECS0004.md

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ECS0004: Replace string.Format with interpolated string
2+
3+
This rule is described in detail in [Effective C#: 50 Specific Ways to Improve your C#](https://www.oreilly.com/library/view/effective-c-50/9780134579290/).
4+
5+
## Cause
6+
7+
Using `string.Format` for formatting strings instead of using interpolated strings.
8+
9+
## Rule description
10+
11+
This rule identifies the usage of `string.Format` method and suggests replacing it with C# interpolated strings. Interpolated strings enhance code readability and reduce the likelihood of runtime errors associated with mismatched format arguments.
12+
13+
## How to fix violations
14+
15+
Replace the usage of `string.Format` with an interpolated string. Interpolated strings are more readable and less error-prone compared to `string.Format`.
16+
17+
## When to suppress warnings
18+
19+
Suppress this warning if you have a specific reason to use `string.Format`, such as when dynamically constructing the format string at runtime or when maintaining compatibility with older code bases that rely on `string.Format`.
20+
21+
## Example of a violation
22+
23+
### Description
24+
25+
Using `string.Format` to format a string.
26+
27+
### Code
28+
29+
```csharp
30+
class Program
31+
{
32+
void Main()
33+
{
34+
var str = string.Format("Hello, {0}!", "world");
35+
}
36+
}
37+
38+
## Example of how to fix
39+
40+
### Description
41+
42+
Replacing `string.Format` with an interpolated string.
43+
44+
### Code
45+
46+
```csharp
47+
class Program
48+
{
49+
void Main()
50+
{
51+
var world = "world";
52+
var str = $"Hello, {world}!";
53+
}
54+
}
55+
```
56+
57+
## Related rules
58+
59+
[ECS0009: Minimize boxing and unboxing](./ECS0009.md)

Diff for: src/EffectiveCSharp.Analyzers/AnalyzerReleases.Unshipped.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Rule ID | Category | Severity | Notes
66
--------|----------|----------|-------
77
ECS0001 | Style | Info | PreferImplicitlyTypedLocalVariablesAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers/blob/e7c151c721c3039011356d6012838f46e4b60a21/docs/ECS0001.md)
88
ECS0002 | Maintainability | Info | PreferReadonlyOverConstAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers/blob/10c2d53afd688efe5a59097f76cb4edf33f6a474/docs/ECS0002.md)
9+
ECS0004 | Style | Info | ReplaceStringFormatAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers5da647e447fad4eb0a9e3db287e1d16cce316114/docs/ECS0004.md)
910
ECS0006 | Refactoring | Info | AvoidStringlyTypedApisAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers6213cba8473dac61d6132e205550884eae1c94bf/docs/ECS0006.md)
1011
ECS0009 | Performance | Info | MinimizeBoxingUnboxingAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers/blob/6213cba8473dac61d6132e205550884eae1c94bf/docs/ECS0009.md)
1112
ECS1000 | Performance | Info | SpanAnalyzer, [Documentation](https://github.com/rjmurillo/EffectiveCSharp.Analyzers/blob/d00a4cc9f61e7d5b392894aad859e46c43a5611c/docs/ECS1000.md)

Diff for: src/EffectiveCSharp.Analyzers/Common/ITypeSymbolExtensions.cs

+10
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,14 @@ or SpecialType.System_Single
1010
or SpecialType.System_Double
1111
or SpecialType.System_Decimal;
1212
}
13+
14+
/// <summary>
15+
/// Determines if the <paramref name="typeSymbol"/> is <see cref="string"/>.
16+
/// </summary>
17+
/// <param name="typeSymbol">The type.</param>
18+
/// <returns>Return true if the type is <see cref="string"/>; otherwise, false.</returns>
19+
internal static bool IsString(this ITypeSymbol typeSymbol)
20+
{
21+
return typeSymbol?.SpecialType == SpecialType.System_String;
22+
}
1323
}

Diff for: src/EffectiveCSharp.Analyzers/DiagnosticIds.cs

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ internal static class DiagnosticIds
44
{
55
internal const string PreferImplicitlyTypedLocalVariables = "ECS0001";
66
internal const string PreferReadonlyOverConst = "ECS0002";
7+
internal const string ReplaceStringFormatWithInterpolatedString = "ECS0004";
78
internal const string AvoidStringlyTypedApis = "ECS0006";
89
internal const string MinimizeBoxingUnboxing = "ECS0009";
910
internal const string BeAwareOfValueTypeCopyInReferenceTypes = "ECS0009";

Diff for: src/EffectiveCSharp.Analyzers/PreferExplicitTypesOnNumbersAnalyzer.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ public class PreferExplicitTypesOnNumbersAnalyzer : DiagnosticAnalyzer
1212
private static readonly DiagnosticDescriptor Rule = new(
1313
id: Id,
1414
title: "Prefer implicitly typed local variables",
15-
description: "Use var to declare local variables for better readability and efficiency, except for built-in numeric types where explicit typing prevents potential conversion issues.",
1615
messageFormat: "Use explicit type instead of 'var' for numeric variables",
1716
category: "Style",
1817
defaultSeverity: DiagnosticSeverity.Info,
1918
isEnabledByDefault: true,
19+
description: "Use var to declare local variables for better readability and efficiency, except for built-in numeric types where explicit typing prevents potential conversion issues.",
2020
helpLinkUri: $"https://github.com/rjmurillo/EffectiveCSharp.Analyzers/blob/{ThisAssembly.GitCommitId}/docs/{Id}.md");
2121

2222
/// <inheritdoc />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace EffectiveCSharp.Analyzers;
4+
5+
/// <summary>
6+
/// A <see cref="DiagnosticAnalyzer"/> for Effective C# Item #4 - Replace string.Format with interpolated string.
7+
/// </summary>
8+
/// <seealso cref="Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer" />
9+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
10+
public class ReplaceStringFormatAnalyzer : DiagnosticAnalyzer
11+
{
12+
private const string Category = "Style";
13+
private const string Description = "Replace string.Format with interpolated string.";
14+
private const string DiagnosticId = DiagnosticIds.ReplaceStringFormatWithInterpolatedString;
15+
private const string MessageFormat = "Replace '{0}' with interpolated string";
16+
private const string Title = "Replace string.Format with interpolated string";
17+
18+
// We can't use source generators
19+
private static readonly Regex PlaceholderRegex = new(
20+
@"\{.*?\}",
21+
RegexOptions.Compiled,
22+
TimeSpan.FromSeconds(1));
23+
24+
private static readonly DiagnosticDescriptor Rule = new(
25+
DiagnosticId,
26+
Title,
27+
MessageFormat,
28+
Category,
29+
DiagnosticSeverity.Info,
30+
isEnabledByDefault: true,
31+
description: Description,
32+
helpLinkUri: $"https://github.com/rjmurillo/EffectiveCSharp.Analyzers{ThisAssembly.GitCommitId}/docs/{DiagnosticId}.md");
33+
34+
/// <inheritdoc/>
35+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
36+
37+
/// <inheritdoc/>
38+
public override void Initialize(AnalysisContext context)
39+
{
40+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
41+
context.EnableConcurrentExecution();
42+
context.RegisterCompilationStartAction(compilationContext =>
43+
{
44+
CSharpParseOptions? parseOptions = compilationContext.Compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions;
45+
if (parseOptions != null && parseOptions.LanguageVersion < LanguageVersion.CSharp10)
46+
{
47+
return;
48+
}
49+
50+
compilationContext.RegisterSyntaxNodeAction(AnalyzeSyntaxNode, SyntaxKind.InvocationExpression);
51+
});
52+
}
53+
54+
private static void AnalyzeSyntaxNode(SyntaxNodeAnalysisContext context)
55+
{
56+
InvocationExpressionSyntax invocationExpr = (InvocationExpressionSyntax)context.Node;
57+
58+
SymbolInfo info = context.SemanticModel.GetSymbolInfo(invocationExpr, context.CancellationToken);
59+
ISymbol? symbol = info.Symbol;
60+
61+
if (symbol is not { Name: "Format" }
62+
|| !symbol.IsStatic
63+
|| !symbol.ContainingType.IsString())
64+
{
65+
return;
66+
}
67+
68+
SeparatedSyntaxList<ArgumentSyntax> argumentList = invocationExpr.ArgumentList.Arguments;
69+
70+
if (argumentList.Count < 2)
71+
{
72+
return;
73+
}
74+
75+
if (argumentList[0].Expression is not LiteralExpressionSyntax formatArgument
76+
|| !formatArgument.IsKind(SyntaxKind.StringLiteralExpression))
77+
{
78+
return;
79+
}
80+
81+
string formatString = formatArgument.Token.ValueText;
82+
if (!ContainsPlaceholders(formatString))
83+
{
84+
return;
85+
}
86+
87+
Diagnostic diagnostic = invocationExpr.GetLocation().CreateDiagnostic(Rule, invocationExpr.ToString());
88+
context.ReportDiagnostic(diagnostic);
89+
}
90+
91+
private static bool ContainsPlaceholders(string formatString)
92+
{
93+
return PlaceholderRegex.IsMatch(formatString);
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace EffectiveCSharp.Analyzers;
4+
5+
/// <summary>
6+
/// A <see cref="CodeFixProvider"/> that provides a code fix for the <see cref="ReplaceStringFormatAnalyzer"/>.
7+
/// </summary>
8+
/// <seealso cref="Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider" />
9+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ReplaceStringFormatCodeFixProvider))]
10+
[Shared]
11+
public class ReplaceStringFormatCodeFixProvider : CodeFixProvider
12+
{
13+
private const string Title = "Replace with interpolated string";
14+
15+
/// <inheritdoc />
16+
public override sealed ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(DiagnosticIds.ReplaceStringFormatWithInterpolatedString);
17+
18+
/// <inheritdoc />
19+
public override sealed FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
20+
21+
/// <inheritdoc />
22+
public override sealed async Task RegisterCodeFixesAsync(CodeFixContext context)
23+
{
24+
Diagnostic diagnostic = context.Diagnostics[0];
25+
TextSpan diagnosticSpan = diagnostic.Location.SourceSpan;
26+
27+
SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
28+
InvocationExpressionSyntax? invocationExpr = root?.FindNode(diagnosticSpan).FirstAncestorOrSelf<InvocationExpressionSyntax>();
29+
30+
context.RegisterCodeFix(
31+
CodeAction.Create(
32+
title: Title,
33+
createChangedSolution: c => ReplaceWithInterpolatedStringAsync(context.Document, invocationExpr, c),
34+
equivalenceKey: Title),
35+
diagnostic);
36+
}
37+
38+
private static bool NeedsParentheses(ExpressionSyntax expression)
39+
{
40+
// Check if the expression is complex and needs to be wrapped in parentheses
41+
return expression is BinaryExpressionSyntax or ConditionalExpressionSyntax or AssignmentExpressionSyntax or CastExpressionSyntax;
42+
}
43+
44+
private static string CreateInterpolatedString(string formatString, ArgumentSyntax[] arguments)
45+
{
46+
string result = formatString;
47+
48+
for (int i = 0; i < arguments.Length; i++)
49+
{
50+
string argumentText = arguments[i].ToString();
51+
52+
// Wrap in parentheses if the argument is a complex expression
53+
if (NeedsParentheses(arguments[i].Expression))
54+
{
55+
argumentText = $"({argumentText})";
56+
}
57+
58+
result = Regex.Replace(result, $@"\{{{i}(.*?)\}}", $"{{{argumentText}$1}}", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
59+
}
60+
61+
return $"$\"{result}\"";
62+
}
63+
64+
private static async Task<Solution> ReplaceWithInterpolatedStringAsync(Document document, InvocationExpressionSyntax? invocationExpr, CancellationToken cancellationToken)
65+
{
66+
if (invocationExpr == null)
67+
{
68+
return document.Project.Solution;
69+
}
70+
71+
LiteralExpressionSyntax? formatStringLiteral = invocationExpr.ArgumentList.Arguments.First().Expression as LiteralExpressionSyntax;
72+
string? formatString = formatStringLiteral?.Token.ValueText;
73+
74+
if (string.IsNullOrEmpty(formatString))
75+
{
76+
return document.Project.Solution;
77+
}
78+
79+
ArgumentSyntax[] arguments = invocationExpr.ArgumentList.Arguments.Skip(1).ToArray();
80+
81+
// Replace format placeholders with corresponding arguments in an interpolated string format
82+
string interpolatedString = CreateInterpolatedString(formatString!, arguments);
83+
SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
84+
SyntaxNode? newRoot = root?.ReplaceNode(invocationExpr, SyntaxFactory.ParseExpression(interpolatedString));
85+
86+
return newRoot != null
87+
? document.WithSyntaxRoot(newRoot).Project.Solution
88+
: document.Project.Solution;
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
namespace EffectiveCSharp.Analyzers.Benchmarks;
2+
3+
[InProcess]
4+
[MemoryDiagnoser]
5+
public class Ecs0004Benchmarks
6+
{
7+
private static CompilationWithAnalyzers? BaselineCompilation { get; set; }
8+
9+
private static CompilationWithAnalyzers? TestCompilation { get; set; }
10+
11+
[IterationSetup]
12+
[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Async setup not supported in BenchmarkDotNet.See https://github.com/dotnet/BenchmarkDotNet/issues/2442.")]
13+
public static void SetupCompilation()
14+
{
15+
List<(string Name, string Content)> sources = [];
16+
for (int index = 0; index < Constants.NumberOfCodeFiles; index++)
17+
{
18+
string name = $"TypeName{index}";
19+
sources.Add((name, @$"
20+
using System;
21+
22+
public class {name}
23+
{{
24+
public void Main()
25+
{{
26+
var str = string.Format(""The value of pi is {{0}}"", Math.PI.ToString(""F2""));
27+
}}
28+
}}
29+
"));
30+
}
31+
32+
(BaselineCompilation, TestCompilation) =
33+
BenchmarkCSharpCompilationFactory
34+
.CreateAsync<ReplaceStringFormatAnalyzer>(sources.ToArray())
35+
.GetAwaiter()
36+
.GetResult();
37+
}
38+
39+
[Benchmark]
40+
public async Task Ecs0004WithDiagnostics()
41+
{
42+
ImmutableArray<Diagnostic> diagnostics =
43+
(await TestCompilation!
44+
.GetAnalysisResultAsync(CancellationToken.None)
45+
.ConfigureAwait(false))
46+
.AssertValidAnalysisResult()
47+
.GetAllDiagnostics();
48+
49+
if (diagnostics.Length != Constants.NumberOfCodeFiles)
50+
{
51+
throw new InvalidOperationException($"Expected '{Constants.NumberOfCodeFiles:N0}' analyzer diagnostics but found '{diagnostics.Length}'");
52+
}
53+
}
54+
55+
[Benchmark(Baseline = true)]
56+
public async Task Ecs0004Baseline()
57+
{
58+
ImmutableArray<Diagnostic> diagnostics =
59+
(await BaselineCompilation!
60+
.GetAnalysisResultAsync(CancellationToken.None)
61+
.ConfigureAwait(false))
62+
.AssertValidAnalysisResult()
63+
.GetAllDiagnostics();
64+
65+
if (diagnostics.Length != 0)
66+
{
67+
throw new InvalidOperationException($"Expected no analyzer diagnostics but found '{diagnostics.Length}'");
68+
}
69+
}
70+
}

Diff for: tests/EffectiveCSharp.Analyzers.Tests/Helpers/ReferenceAssemblyCatalog.cs

+2
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ internal static class ReferenceAssemblyCatalog
6161
{ Net80, ReferenceAssemblies.Net.Net80 },
6262
{ Net90, ReferenceAssemblies.Net.Net90 },
6363
};
64+
65+
public static IReadOnlySet<string> DotNetCore { get; } = new HashSet<string>([Net60, Net80, Net90], StringComparer.Ordinal);
6466
}

0 commit comments

Comments
 (0)