Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
00af2aa
Initial plan
Copilot Aug 9, 2025
9d34177
Add basic CodeFixProvider stub for PH2145
Copilot Aug 9, 2025
7a4b1e7
Work in progress: CodeFixProvider with strict formatting challenges
Copilot Aug 9, 2025
870299a
Implement CodeFixProvider for PH2145: Avoid string.Format in interpol…
Copilot Aug 10, 2025
5fcc4ee
Address reviewer feedback: remove unwanted file edits and update docu…
Copilot Aug 10, 2025
a491352
Merge branch 'main' into copilot/fix-878
bcollamore Aug 11, 2025
1b99fd3
fix: Resolve formatting issues in AvoidStringFormatInInterpolatedStri…
Copilot Aug 11, 2025
b87c552
Merge branch 'main' into copilot/fix-878
bcollamore Aug 11, 2025
847c6dc
fix: Add CultureInfo.InvariantCulture to TryParse call to resolve PH2…
Copilot Aug 11, 2025
a78bf5b
Merge branch 'main' into copilot/fix-878
bcollamore Aug 11, 2025
b901914
Merge branch 'main' into copilot/fix-878
bcollamore Aug 12, 2025
515fe99
refactor: Reduce cognitive complexity of PH2145 CodeFixProvider and i…
Copilot Aug 12, 2025
b8bb09d
perf: Optimize CodeFixProvider by checking method name before semanti…
Copilot Aug 12, 2025
8a8bfa5
Merge branch 'main' into copilot/fix-878
bcollamore Aug 13, 2025
7116e4e
feat: Add comprehensive test coverage for PH2145 CodeFixProvider to r…
Copilot Aug 13, 2025
a56d231
refactor: reduce cognitive complexity in ConvertStringFormatToInterpo…
Copilot Aug 13, 2025
01d5ef3
Merge branch 'main' into copilot/fix-878
bcollamore Aug 14, 2025
99a935c
Merge branch 'main' into copilot/fix-878
bcollamore Aug 14, 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
6 changes: 4 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ When creating new analyzers:
2. **Set `isEnabledByDefault: false`** initially for testing
3. **Add to appropriate project** (Maintainability, Security, etc.)
4. **Create comprehensive tests** in Philips.CodeAnalysis.Test
5. **Update documentation** in Documentation/ folder
6. **Use current year in copyright** headers: `// © 2025 Koninklijke Philips N.V.`
5. **Ensure 80% code coverage** minimum for all modified code
6. **Update documentation** in Documentation/ folder
7. **Use current year in copyright** headers: `// © 2025 Koninklijke Philips N.V.`

### Performance Considerations
Analyzers run during compilation and must be performant:
Expand All @@ -139,6 +140,7 @@ Analyzers run during compilation and must be performant:
### Pull Request Requirements
- **Title format**: Must follow Conventional Commits (feat:, fix:, docs:, etc.)
- **All tests pass**: 1903 tests must pass
- **Code coverage**: Modified code must have at least 80% coverage
- **Formatting perfect**: Zero formatting violations
- **No suppressions**: Fix underlying issues, don't suppress warnings
- **Documentation**: Update relevant docs in Documentation/ folder
Expand Down
2 changes: 1 addition & 1 deletion Documentation/Diagnostics/PH2145.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
| Diagnostic ID | PH2145 |
| Category | [Maintainability](../Maintainability.md) |
| Analyzer | [AvoidStringFormatInInterpolatedStringAnalyzer](https://github.com/philips-software/roslyn-analyzers/blob/main/Philips.CodeAnalysis.MaintainabilityAnalyzers/Maintainability/AvoidStringFormatInInterpolatedStringAnalyzer.cs)
| CodeFix | No |
| CodeFix | Yes |
| Severity | Info |
| Enabled By Default | No |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// © 2025 Koninklijke Philips N.V. See License.md in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Philips.CodeAnalysis.Common;

namespace Philips.CodeAnalysis.MaintainabilityAnalyzers.Maintainability
{
[ExportCodeFixProvider(LanguageNames.CSharp,
Name = nameof(AvoidStringFormatInInterpolatedStringCodeFixProvider)), Shared]
public class AvoidStringFormatInInterpolatedStringCodeFixProvider :
SingleDiagnosticCodeFixProvider<InterpolationSyntax>
{
protected override string Title => "Remove redundant string.Format call";

protected override DiagnosticId DiagnosticId =>
DiagnosticId.AvoidStringFormatInInterpolatedString;

protected override async Task<Document> ApplyFix(Document document, InterpolationSyntax node,

Choose a reason for hiding this comment

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

Refactor this method to reduce its cognitive complexity. Can it be reduced by introducing a helper method or 2?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored the ApplyFix method to reduce cognitive complexity by extracting three helper methods:

  • TryExtractStringFormatInfo: Handles validation and extraction of format information
  • IsStringFormatInvocation: Verifies the semantic model for string.Format calls
  • ApplyInterpolatedStringTransformation: Handles the syntax tree transformation

This makes the code more maintainable and easier to understand. Commit: 515fe99

ImmutableDictionary<string, string> properties, CancellationToken cancellationToken)
{
SyntaxNode root = await document.GetSyntaxRootAsync(cancellationToken);

// Validate and extract string.Format information
if (!TryExtractStringFormatInfo(node, out InvocationExpressionSyntax invocation, out var formatString, out ArgumentSyntax[] arguments))
{
return document;
}

// Verify this is actually a string.Format call
if (!await IsStringFormatInvocation(document, invocation, cancellationToken))

Choose a reason for hiding this comment

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

Hmm, wouldn't this be a bug in the Analyzer ??

{
return document;
}

// Find the parent interpolated string and apply the transformation
return await ApplyInterpolatedStringTransformation(document, root, node, formatString, arguments);
}

private bool TryExtractStringFormatInfo(InterpolationSyntax node, out InvocationExpressionSyntax invocation, out string formatString, out ArgumentSyntax[] arguments)
{
invocation = null;
formatString = null;
arguments = null;

// Get the string.Format invocation from the interpolation
if (node.Expression is not InvocationExpressionSyntax inv)
{
return false;
}

invocation = inv;

// Quick syntactic check for "Format" method name before expensive semantic analysis
if (!IsFormatMethodSyntactically(invocation))
{
return false;
}

// Extract format string and arguments
if (invocation.ArgumentList.Arguments.Count < 1)
{
return false;
}

ArgumentSyntax formatArgument = invocation.ArgumentList.Arguments[0];
if (formatArgument.Expression is not LiteralExpressionSyntax formatLiteral ||
!formatLiteral.Token.IsKind(SyntaxKind.StringLiteralToken))
{
return false;
}

formatString = formatLiteral.Token.ValueText;
arguments = invocation.ArgumentList.Arguments.Skip(1).ToArray();
return true;
}

private static bool IsFormatMethodSyntactically(InvocationExpressionSyntax invocation)
{
return invocation.Expression switch
{
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText == "Format",
IdentifierNameSyntax identifier => identifier.Identifier.ValueText == "Format",
_ => false
};
}

private async Task<bool> IsStringFormatInvocation(Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken)
{
SemanticModel semanticModel = await document.GetSemanticModelAsync(cancellationToken);

Choose a reason for hiding this comment

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

Because getting the semantic model is computationally expensive, we should check for "Format" before, rather than after.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Optimized performance by adding IsFormatMethodSyntactically method that checks for "Format" method name before getting the expensive semantic model. The semantic model is now only accessed after confirming the method name matches. Commit: b8bb09d

SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken);
return symbolInfo.Symbol is IMethodSymbol methodSymbol &&
methodSymbol.ContainingType?.SpecialType == SpecialType.System_String;
}

private Task<Document> ApplyInterpolatedStringTransformation(Document document, SyntaxNode root, InterpolationSyntax node, string formatString, ArgumentSyntax[] arguments)
{
// Find the parent interpolated string
InterpolatedStringExpressionSyntax interpolatedString = node.FirstAncestorOrSelf<InterpolatedStringExpressionSyntax>();
if (interpolatedString == null)
{
return Task.FromResult(document);
}

// Convert the string.Format to interpolated content
List<InterpolatedStringContentSyntax> newContents = ConvertStringFormatToInterpolatedContents(formatString, arguments);

// Get current contents and find the position of the node to replace
var currentContents = interpolatedString.Contents.ToList();
var index = currentContents.IndexOf(node);
if (index == -1)
{
return Task.FromResult(document);
}

// Remove the current interpolation and insert the new contents
currentContents.RemoveAt(index);
if (newContents.Count > 0)
{
currentContents.InsertRange(index, newContents);
}
// If newContents is empty (empty format string case), we just remove the interpolation

// Create new interpolated string with the updated contents
InterpolatedStringExpressionSyntax newInterpolatedString = interpolatedString.WithContents(SyntaxFactory.List(currentContents));

// Replace in the syntax tree
root = root.ReplaceNode(interpolatedString, newInterpolatedString);

return Task.FromResult(document.WithSyntaxRoot(root));
}

private List<InterpolatedStringContentSyntax> ConvertStringFormatToInterpolatedContents(string formatString, ArgumentSyntax[] arguments)
{
var result = new List<InterpolatedStringContentSyntax>();
var placeholderPattern = new Regex(@"\{(\d+)(?::([^}]*))?\}",

Choose a reason for hiding this comment

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

Can this large method be refactored to reduce its cognitive complexity?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored the method to reduce cognitive complexity by extracting 5 helper methods:

  • AddTextContent: Handles adding text content to result list
  • TryProcessPlaceholder: Processes placeholder matches
  • CreateInterpolationWithFormat: Creates interpolations with format specifiers
  • HandleRemainingContent: Handles remaining text and special cases
  • CreateInterpolatedStringText: Creates interpolated string text tokens

Reduced the main method from ~88 lines to ~28 lines with better separation of concerns. All 15 tests continue to pass. Commit: a56d231

Choose a reason for hiding this comment

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

Place the Regex as a constant at the top of the file and make it Compiled and add a Timeout.
Didn't the DogFood pipeline catch this one?

RegexOptions.None, TimeSpan.FromSeconds(1));
var lastIndex = 0;

MatchCollection matches = placeholderPattern.Matches(formatString);

foreach (Match match in matches)
{
// Add text before the placeholder
AddTextContent(result, formatString, lastIndex, match.Index);

// Process the placeholder
if (!TryProcessPlaceholder(result, match, arguments))
{
// Invalid placeholder - return empty collection for graceful handling
return [];
}

lastIndex = match.Index + match.Length;
}

// Add remaining text and handle special cases
HandleRemainingContent(result, formatString, lastIndex, matches.Count);

return result;
}

private static void AddTextContent(List<InterpolatedStringContentSyntax> result, string formatString, int startIndex, int endIndex)
{
if (endIndex > startIndex)
{
var textContent = formatString.Substring(startIndex, endIndex - startIndex);
if (!string.IsNullOrEmpty(textContent))
{
result.Add(CreateInterpolatedStringText(textContent));
}
}
}

private static bool TryProcessPlaceholder(List<InterpolatedStringContentSyntax> result, Match match, ArgumentSyntax[] arguments)
{
if (!int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var argIndex) ||
argIndex >= arguments.Length)
{
return false;
}

ExpressionSyntax expression = arguments[argIndex].Expression;
InterpolationSyntax interpolation = CreateInterpolationWithFormat(expression, match.Groups[2]);
result.Add(interpolation);
return true;
}

private static InterpolationSyntax CreateInterpolationWithFormat(ExpressionSyntax expression, Group formatGroup)
{
if (formatGroup.Success && !string.IsNullOrEmpty(formatGroup.Value))
{
InterpolationFormatClauseSyntax formatClause = SyntaxFactory.InterpolationFormatClause(
SyntaxFactory.Token(SyntaxKind.ColonToken),
SyntaxFactory.Token(SyntaxTriviaList.Empty, SyntaxKind.InterpolatedStringTextToken,
formatGroup.Value, formatGroup.Value, SyntaxTriviaList.Empty));
return SyntaxFactory.Interpolation(expression, null, formatClause);
}

return SyntaxFactory.Interpolation(expression);
}

private static void HandleRemainingContent(List<InterpolatedStringContentSyntax> result, string formatString, int lastIndex, int matchCount)
{
// Add remaining text after all placeholders
if (lastIndex < formatString.Length)
{
var remainingText = formatString.Substring(lastIndex);
if (!string.IsNullOrEmpty(remainingText))
{
result.Add(CreateInterpolatedStringText(remainingText));
}
}

// Handle special case: text-only format string with no placeholders
if (result.Count == 0 && matchCount == 0 && !string.IsNullOrEmpty(formatString))
{
result.Add(CreateInterpolatedStringText(formatString));
}
}

private static InterpolatedStringTextSyntax CreateInterpolatedStringText(string text)
{
SyntaxToken textToken = SyntaxFactory.Token(SyntaxTriviaList.Empty,
SyntaxKind.InterpolatedStringTextToken,
text, text, SyntaxTriviaList.Empty);
return SyntaxFactory.InterpolatedStringText(textToken);
}
}
}
Loading
Loading