Skip to content

Commit 0394829

Browse files
Copilotstephentoub
andauthored
Add CA1877: Collapse nested Path.Combine/Path.Join calls (#51456)
Co-authored-by: Stephen Toub <[email protected]>
1 parent 2adaf93 commit 0394829

22 files changed

+1121
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.
2+
3+
using System.Collections.Immutable;
4+
using System.Composition;
5+
using Analyzer.Utilities;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
using Microsoft.NetCore.Analyzers;
12+
using Microsoft.NetCore.Analyzers.Performance;
13+
14+
namespace Microsoft.NetCore.CSharp.Analyzers.Performance
15+
{
16+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
17+
public sealed class CSharpCollapseMultiplePathOperationsFixer : CodeFixProvider
18+
{
19+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(CollapseMultiplePathOperationsAnalyzer.RuleId);
20+
21+
public override FixAllProvider GetFixAllProvider()
22+
=> WellKnownFixAllProviders.BatchFixer;
23+
24+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
25+
{
26+
var document = context.Document;
27+
var diagnostic = context.Diagnostics[0];
28+
var root = await document.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
29+
var node = root.FindNode(context.Span, getInnermostNodeForTie: true);
30+
31+
if (node is not InvocationExpressionSyntax invocation ||
32+
await document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false) is not { } semanticModel ||
33+
semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemIOPath) is not { } pathType)
34+
{
35+
return;
36+
}
37+
38+
// Get the method name from diagnostic properties
39+
if (!diagnostic.Properties.TryGetValue(CollapseMultiplePathOperationsAnalyzer.MethodNameKey, out var methodName))
40+
{
41+
methodName = "Path";
42+
}
43+
44+
context.RegisterCodeFix(
45+
CodeAction.Create(
46+
string.Format(MicrosoftNetCoreAnalyzersResources.CollapseMultiplePathOperationsCodeFixTitle, methodName),
47+
createChangedDocument: cancellationToken => CollapsePathOperationAsync(document, root, invocation, pathType, semanticModel, cancellationToken),
48+
equivalenceKey: nameof(MicrosoftNetCoreAnalyzersResources.CollapseMultiplePathOperationsCodeFixTitle)),
49+
diagnostic);
50+
}
51+
52+
private static Task<Document> CollapsePathOperationAsync(Document document, SyntaxNode root, InvocationExpressionSyntax invocation, INamedTypeSymbol pathType, SemanticModel semanticModel, CancellationToken cancellationToken)
53+
{
54+
// Collect all arguments by recursively unwrapping nested Path.Combine/Join calls
55+
var allArguments = CollectAllArguments(invocation, pathType, semanticModel);
56+
57+
// Create new argument list with all collected arguments
58+
var newArgumentList = SyntaxFactory.ArgumentList(
59+
SyntaxFactory.SeparatedList(allArguments));
60+
61+
// Create the new invocation with all arguments
62+
var newInvocation = invocation.WithArgumentList(newArgumentList)
63+
.WithTriviaFrom(invocation);
64+
65+
var newRoot = root.ReplaceNode(invocation, newInvocation);
66+
67+
return Task.FromResult(document.WithSyntaxRoot(newRoot));
68+
}
69+
70+
private static ArgumentSyntax[] CollectAllArguments(InvocationExpressionSyntax invocation, INamedTypeSymbol pathType, SemanticModel semanticModel)
71+
{
72+
var arguments = ImmutableArray.CreateBuilder<ArgumentSyntax>();
73+
74+
foreach (var argument in invocation.ArgumentList.Arguments)
75+
{
76+
if (argument.Expression is InvocationExpressionSyntax nestedInvocation &&
77+
IsPathCombineOrJoin(nestedInvocation, pathType, semanticModel, out var methodName) &&
78+
IsPathCombineOrJoin(invocation, pathType, semanticModel, out var outerMethodName) &&
79+
methodName == outerMethodName)
80+
{
81+
// Recursively collect arguments from nested invocation
82+
arguments.AddRange(CollectAllArguments(nestedInvocation, pathType, semanticModel));
83+
}
84+
else
85+
{
86+
arguments.Add(argument);
87+
}
88+
}
89+
90+
return arguments.ToArray();
91+
}
92+
93+
private static bool IsPathCombineOrJoin(InvocationExpressionSyntax invocation, INamedTypeSymbol pathType, SemanticModel semanticModel, out string methodName)
94+
{
95+
if (semanticModel.GetSymbolInfo(invocation).Symbol is IMethodSymbol methodSymbol &&
96+
SymbolEqualityComparer.Default.Equals(methodSymbol.ContainingType, pathType) &&
97+
methodSymbol.Name is "Combine" or "Join")
98+
{
99+
methodName = methodSymbol.Name;
100+
return true;
101+
}
102+
103+
methodName = string.Empty;
104+
return false;
105+
}
106+
}
107+
}

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,6 +1920,18 @@ Using 'AsParallel()' directly in a 'foreach' loop has no effect. The 'foreach' s
19201920
|CodeFix|False|
19211921
---
19221922

1923+
## [CA1877](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1877): Collapse consecutive Path.Combine or Path.Join operations
1924+
1925+
When multiple Path.Combine or Path.Join operations are nested, they can be collapsed into a single operation for better performance and readability.
1926+
1927+
|Item|Value|
1928+
|-|-|
1929+
|Category|Performance|
1930+
|Enabled|True|
1931+
|Severity|Info|
1932+
|CodeFix|True|
1933+
---
1934+
19231935
## [CA2000](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2000): Dispose objects before losing scope
19241936

19251937
If a disposable object is not explicitly disposed before all references to it are out of scope, the object will be disposed at some indeterminate time when the garbage collector runs the finalizer of the object. Because an exceptional event might occur that will prevent the finalizer of the object from running, the object should be explicitly disposed instead.

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3527,6 +3527,26 @@
35273527
]
35283528
}
35293529
},
3530+
"CA1877": {
3531+
"id": "CA1877",
3532+
"shortDescription": "Collapse consecutive Path.Combine or Path.Join operations",
3533+
"fullDescription": "When multiple Path.Combine or Path.Join operations are nested, they can be collapsed into a single operation for better performance and readability.",
3534+
"defaultLevel": "note",
3535+
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1877",
3536+
"properties": {
3537+
"category": "Performance",
3538+
"isEnabledByDefault": true,
3539+
"typeName": "CollapseMultiplePathOperationsAnalyzer",
3540+
"languages": [
3541+
"C#",
3542+
"Visual Basic"
3543+
],
3544+
"tags": [
3545+
"Telemetry",
3546+
"EnabledRuleInAggressiveMode"
3547+
]
3548+
}
3549+
},
35303550
"CA2000": {
35313551
"id": "CA2000",
35323552
"shortDescription": "Dispose objects before losing scope",

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CA1873 | Performance | Info | AvoidPotentiallyExpensiveCallWhenLoggingAnalyzer,
88
CA1874 | Performance | Info | UseRegexMembers, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1874)
99
CA1875 | Performance | Info | UseRegexMembers, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875)
1010
CA1876 | Performance | Info | DoNotUseAsParallelInForEachLoopAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876)
11+
CA1877 | Performance | Info | CollapseMultiplePathOperationsAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/cA1877)
1112
CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2023)
1213
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
1314
CA2025 | Reliability | Disabled | DoNotPassDisposablesIntoUnawaitedTasksAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2025)

src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2249,4 +2249,16 @@ Widening and user defined conversions are not supported with generic types.</val
22492249
<data name="DoNotUseThreadVolatileReadWriteCodeFixTitle" xml:space="preserve">
22502250
<value>Replace obsolete call</value>
22512251
</data>
2252+
<data name="CollapseMultiplePathOperationsTitle" xml:space="preserve">
2253+
<value>Collapse consecutive Path.Combine or Path.Join operations</value>
2254+
</data>
2255+
<data name="CollapseMultiplePathOperationsMessage" xml:space="preserve">
2256+
<value>Multiple consecutive Path.{0} operations can be collapsed into a single operation</value>
2257+
</data>
2258+
<data name="CollapseMultiplePathOperationsDescription" xml:space="preserve">
2259+
<value>When multiple Path.Combine or Path.Join operations are nested, they can be collapsed into a single operation for better performance and readability.</value>
2260+
</data>
2261+
<data name="CollapseMultiplePathOperationsCodeFixTitle" xml:space="preserve">
2262+
<value>Collapse into single Path.{0} operation</value>
2263+
</data>
22522264
</root>

0 commit comments

Comments
 (0)