Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
002584d
Initial plan
Copilot Jul 24, 2025
4e72613
Add basic PreferInterpolatedStringAnalyzer (detection logic needs deb…
Copilot Jul 24, 2025
bc5adfb
Fix analyzer logic to only trigger on string.Format with placeholders
Copilot Jul 24, 2025
dfd325a
Update DiagnosticId to 2145 and add documentation as requested
Copilot Jul 26, 2025
a35ecbc
Fix build failures - corrected file formatting and encoding
Copilot Jul 26, 2025
c2d1cd6
Address PR feedback: improve format string parsing and handle escaped…
Copilot Jul 26, 2025
be6d1b3
Add support for detecting unnecessary string.Format calls and compreh…
Copilot Jul 26, 2025
402fdf4
Fix build failures: correct formatting, line endings, and escaped bra…
Copilot Jul 27, 2025
dabcfd4
Merge branch 'main' into copilot/fix-218
bcollamore Jul 28, 2025
5429c27
Fix build failures by converting string.Format calls to interpolated …
Copilot Jul 28, 2025
8197765
Fix remaining dogfooding build error by converting string.Format to i…
Copilot Jul 30, 2025
e3f6629
Update DiagnosticId from PH2145 to PH2148 as requested
Copilot Jul 30, 2025
5ec20f1
Merge branch 'main' into copilot/fix-218
bcollamore Jul 30, 2025
1dcd55b
Fix dogfooding build error by converting string.Format to interpolate…
Copilot Jul 30, 2025
f9897f2
Fix dogfooding build error: Add culture specification to TryParse call
Copilot Jul 30, 2025
badb218
Fix dogfooding build errors by converting string.Format calls to inte…
Copilot Jul 30, 2025
5f2b714
Merge branch 'main' into copilot/fix-218
bcollamore Aug 3, 2025
bc159f9
Set PH2148 PreferInterpolatedString analyzer to disabled by default
Copilot Aug 3, 2025
b274f33
Merge branch 'main' into copilot/fix-218
bcollamore Aug 4, 2025
5362bae
Delete Philips.CodeAnalysis.Test/MsTest/TestMethodsMustHaveTheCorrect…
bcollamore Aug 8, 2025
6b32b79
Merge branch 'main' into copilot/fix-218
bcollamore Aug 8, 2025
bd1bd13
Merge branch 'main' into copilot/fix-218
bcollamore Aug 12, 2025
f3ec3ca
refactor: Improve PreferInterpolatedStringAnalyzer API design and per…
Copilot Aug 13, 2025
cd7cf12
Merge branch 'main' into copilot/fix-218
bcollamore Aug 25, 2025
f2adad9
fix: Handle alignment options, IFormatProvider overloads, and interpo…
Copilot Aug 26, 2025
33bf07c
Merge branch 'main' into copilot/fix-218
bcollamore Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions Documentation/Diagnostics/PH2148.md
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.
3 changes: 1 addition & 2 deletions Philips.CodeAnalysis.AnalyzerPerformance/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// © 2023 Koninklijke Philips N.V. See License.md in the project root for license information.

using Microsoft.Build.Logging.StructuredLogger;
using Philips.CodeAnalysis.Common;

namespace Philips.CodeAnalysis.AnalyzerPerformance
Expand Down Expand Up @@ -36,7 +35,7 @@
}
}

private static void AnalyzePackages(NamedNode namedNode)

Check failure on line 38 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'NamedNode' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 38 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'NamedNode' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 38 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'NamedNode' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 38 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'NamedNode' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (BaseNode analyzerPackageNode in namedNode.Children)
{
Expand Down Expand Up @@ -76,13 +75,13 @@
}
}

private static void AnalyzerItems(Folder namedAnalyzerPackageFolder)

Check failure on line 78 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'Folder' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 78 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'Folder' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 78 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'Folder' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 78 in Philips.CodeAnalysis.AnalyzerPerformance/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'Folder' could not be found (are you missing a using directive or an assembly reference?)
{
foreach (BaseNode analyzerMessage in namedAnalyzerPackageFolder.Children)
{
if (analyzerMessage is Item item)
{
var record = AnalyzerPerformanceRecord.TryParse(item.Title);
AnalyzerPerformanceRecord record = AnalyzerPerformanceRecord.TryParse(item.Title);
Records.Add(record);
}
}
Expand Down
5 changes: 0 additions & 5 deletions Philips.CodeAnalysis.Benchmark/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Philips.CodeAnalysis.DuplicateCodeAnalyzer;

namespace Philips.CodeAnalysis.Benchmark
Expand All @@ -24,7 +19,7 @@
public class InputDataSet
{
public string Folder { get; set; }
public IReadOnlyDictionary<MethodDeclarationSyntax, IEnumerable<SyntaxToken>> Data { get; set; }

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'SyntaxToken' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'MethodDeclarationSyntax' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'SyntaxToken' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'MethodDeclarationSyntax' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'SyntaxToken' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'MethodDeclarationSyntax' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'SyntaxToken' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 22 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'MethodDeclarationSyntax' could not be found (are you missing a using directive or an assembly reference?)

public override string ToString()
{
Expand All @@ -32,7 +27,7 @@
}
}

[SimpleJob(launchCount: LaunchCount, warmupCount: WarmupCount, targetCount: TargetCount)]

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'SimpleJob' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'SimpleJobAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'SimpleJob' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'SimpleJobAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'SimpleJob' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'SimpleJobAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'SimpleJob' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 30 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'SimpleJobAttribute' could not be found (are you missing a using directive or an assembly reference?)
public class DuplicationDetectorBenchmark
{
public const int LaunchCount = 3;
Expand All @@ -43,7 +38,7 @@
private const int BaseModulus2 = 227;
private const int Modulus2 = 1000005;

[ParamsSource(nameof(ValuesForA))]

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'ParamsSource' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

The type or namespace name 'ParamsSourceAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'ParamsSource' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

The type or namespace name 'ParamsSourceAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'ParamsSource' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

The type or namespace name 'ParamsSourceAttribute' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'ParamsSource' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 41 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

The type or namespace name 'ParamsSourceAttribute' could not be found (are you missing a using directive or an assembly reference?)
public InputDataSet A { get; set; }

// public property
Expand Down Expand Up @@ -97,14 +92,14 @@
});
}

[Benchmark]

Check failure on line 95 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 95 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 95 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 95 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

'Philips.CodeAnalysis.Benchmark' is not an attribute class
public void OriginalHashParameters()
{
DuplicateDetector library = new();
TestDictionary(library, BaseModulus1, Modulus1);
}

[Benchmark]

Check failure on line 102 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / performance / build

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 102 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / dogfood / Dogfood Analyzers

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 102 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / build / build

'Philips.CodeAnalysis.Benchmark' is not an attribute class

Check failure on line 102 in Philips.CodeAnalysis.Benchmark/Program.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

'Philips.CodeAnalysis.Benchmark' is not an attribute class
public void BiggerPrimes()
{
DuplicateDetector library = new();
Expand Down
1 change: 1 addition & 0 deletions Philips.CodeAnalysis.Common/DiagnosticId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public enum DiagnosticId
AvoidStringFormatInInterpolatedString = 2145,
AvoidToStringOnString = 2146,
AvoidVariableNamedUnderscore = 2147,
PreferInterpolatedString = 2148,
AvoidProblematicUsingPatterns = 2149,
AvoidTodoComments = 2151,
AvoidUnusedToString = 2153,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,23 +111,22 @@
public override async Task<CodeAction> GetFixAsync(FixAllContext fixAllContext)
{
var diagnosticsToFix = new List<KeyValuePair<Project, ImmutableArray<Diagnostic>>>();
var titleFormat = "Add all duplications in {0} {1} to the exceptions list";
string title = null;
switch (fixAllContext.Scope)
{
case FixAllScope.Document:
{
ImmutableArray<Diagnostic> diagnostics = await fixAllContext.GetDocumentDiagnosticsAsync(fixAllContext.Document).ConfigureAwait(false);
diagnosticsToFix.Add(new KeyValuePair<Project, ImmutableArray<Diagnostic>>(fixAllContext.Project, diagnostics));
title = string.Format(titleFormat, "document", fixAllContext.Document.Name);
title = $"Add all duplications in document {fixAllContext.Document.Name} to the exceptions list";
break;
}
case FixAllScope.Project:
{
Project project = fixAllContext.Project;
ImmutableArray<Diagnostic> diagnostics = await fixAllContext.GetAllDiagnosticsAsync(project).ConfigureAwait(false);
diagnosticsToFix.Add(new KeyValuePair<Project, ImmutableArray<Diagnostic>>(fixAllContext.Project, diagnostics));
title = string.Format(titleFormat, "project", fixAllContext.Project.Name);
title = $"Add all duplications in project {fixAllContext.Project.Name} to the exceptions list";
break;
}
case FixAllScope.Solution:
Expand Down Expand Up @@ -197,7 +196,7 @@
}

StringBuilder appending = new();
foreach (var methodName in newMethodNames)

Check warning on line 199 in Philips.CodeAnalysis.DuplicateCodeAnalyzer/AvoidDuplicateCodeFixProvider.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Remove this call, the collection is known to be empty here. (https://rules.sonarsource.com/csharp/RSPEC-4158)

Check warning on line 199 in Philips.CodeAnalysis.DuplicateCodeAnalyzer/AvoidDuplicateCodeFixProvider.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Remove this call, the collection is known to be empty here. (https://rules.sonarsource.com/csharp/RSPEC-4158)

Check warning on line 199 in Philips.CodeAnalysis.DuplicateCodeAnalyzer/AvoidDuplicateCodeFixProvider.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Remove this call, the collection is known to be empty here. (https://rules.sonarsource.com/csharp/RSPEC-4158)

Check warning on line 199 in Philips.CodeAnalysis.DuplicateCodeAnalyzer/AvoidDuplicateCodeFixProvider.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Remove this call, the collection is known to be empty here. (https://rules.sonarsource.com/csharp/RSPEC-4158)
{
_ = appending.Append(methodName);
_ = appending.Append(Environment.NewLine);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ namespace Philips.CodeAnalysis.MaintainabilityAnalyzers.Maintainability
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AlignOperatorsCountAnalyzer : DiagnosticAnalyzerBase
{
private const string Title = "Align number of {0} and {1} operators.";
private const string MessageFormat = Title;
private const string DescriptionFormat =
"Overload the {1} operator, when you overload the {0} operator, as they are often used in combination with each other.";
private const string Category = Categories.Maintainability;
private const string Plus = "+";

Expand Down Expand Up @@ -47,12 +43,12 @@ private static DiagnosticDescriptor GenerateRule(string first, string second, Di
{
return new(
diagnosticId.ToId(),
string.Format(Title, first, second),
string.Format(MessageFormat, first, second),
$"Align number of {first} and {second} operators.",
$"Align number of {first} and {second} operators.",
Category,
DiagnosticSeverity.Error,
isEnabledByDefault: false,
description: string.Format(DescriptionFormat, first, second)
description: $"Overload the {second} operator, when you overload the {first} operator, as they are often used in combination with each other."
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ public class NamespacePrefixAnalyzer : DiagnosticAnalyzerBase


private const string TitleForEmptyPrefix = @"Specify the namespace prefix in the .editorconfig file";
private const string MessageFormatForEmptyPrefix = @"Please specify the namespace prefix in the .editorconfig file Eg. dotnet_code_quality.{0}.namespace_prefix = [OrganizationName].[ProductName]";
private const string DescriptionForEmptyPrefix = MessageFormatForEmptyPrefix;
private const string Category = Categories.Naming;

private void Analyze(SyntaxNodeAnalysisContext context)
Expand Down Expand Up @@ -49,7 +47,7 @@ private void Analyze(SyntaxNodeAnalysisContext context)
public static readonly string RuleId = DiagnosticId.NamespacePrefix.ToId();

public static readonly DiagnosticDescriptor RuleForIncorrectNamespace = new(RuleId, TitleForIncorrectPrefix, MessageFormatForIncorrectPrefix, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DescriptionForIncorrectPrefix);
public static readonly DiagnosticDescriptor RuleForEmptyPrefix = new(RuleId, TitleForEmptyPrefix, string.Format(MessageFormatForEmptyPrefix, RuleId), Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: string.Format(DescriptionForEmptyPrefix, RuleId));
public static readonly DiagnosticDescriptor RuleForEmptyPrefix = new(RuleId, TitleForEmptyPrefix, $"Please specify the namespace prefix in the .editorconfig file Eg. dotnet_code_quality.{RuleId}.namespace_prefix = [OrganizationName].[ProductName]", Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: $"Please specify the namespace prefix in the .editorconfig file Eg. dotnet_code_quality.{RuleId}.namespace_prefix = [OrganizationName].[ProductName]");


public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(RuleForIncorrectNamespace, RuleForEmptyPrefix); } }
Expand Down
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

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Rename the enumeration 'ConversionResult' to match the regular expression: '^([A-Z]{1,3}[a-z0-9]+)*([A-Z]{2})?s$'. (https://rules.sonarsource.com/csharp/RSPEC-2342)

Check warning on line 17 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Rename the enumeration 'ConversionResult' to match the regular expression: '^([A-Z]{1,3}[a-z0-9]+)*([A-Z]{2})?s$'. (https://rules.sonarsource.com/csharp/RSPEC-2342)

Check warning on line 17 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Rename the enumeration 'ConversionResult' to match the regular expression: '^([A-Z]{1,3}[a-z0-9]+)*([A-Z]{2})?s$'. (https://rules.sonarsource.com/csharp/RSPEC-2342)
{
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;
}

Copy link
Copy Markdown
Member

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)

Copy link
Copy Markdown
Contributor Author

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 test NoWarningForIFormatProviderOverload to verify this behavior.

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle string.Format($"blah blah blah"). (unit test)

Copy link
Copy Markdown
Contributor Author

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 when the format string itself is an interpolated string (e.g., string.Format($"Hello {name}, value: {{0}}", value)) by checking for OperationKind.InterpolatedString and gracefully bails out. Added unit test NoWarningForInterpolatedStringAsFormat to verify this behavior.

// 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

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 141 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 141 in Philips.CodeAnalysis.MaintainabilityAnalyzers/Readability/PreferInterpolatedStringAnalyzer.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / SonarCloud

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
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 ':'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Contributor Author

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 alignment options like {0,10} and {0,10:N2} and gracefully bails out since interpolated strings don't support this syntax directly. Added unit tests NoWarningForAlignmentOptions and NoWarningForAlignmentWithFormatSpecifier to verify this behavior.

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;
}
}
}
Loading
Loading