Skip to content

Commit 4bb5ef4

Browse files
Copilotstephentoub
andcommitted
Add Path.Combine/Join collapsing analyzer with fixers and tests
Co-authored-by: stephentoub <[email protected]>
1 parent 948457b commit 4bb5ef4

22 files changed

+1037
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Analyzer.Utilities;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CodeActions;
11+
using Microsoft.CodeAnalysis.CodeFixes;
12+
using Microsoft.CodeAnalysis.CSharp;
13+
using Microsoft.CodeAnalysis.CSharp.Syntax;
14+
using Microsoft.CodeAnalysis.Editing;
15+
using Microsoft.NetCore.Analyzers;
16+
using Microsoft.NetCore.Analyzers.Performance;
17+
18+
namespace Microsoft.NetCore.CSharp.Analyzers.Performance
19+
{
20+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
21+
public sealed class CSharpCollapseMultiplePathOperationsFixer : CodeFixProvider
22+
{
23+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(CollapseMultiplePathOperationsAnalyzer.RuleId);
24+
25+
public override FixAllProvider GetFixAllProvider()
26+
=> WellKnownFixAllProviders.BatchFixer;
27+
28+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
29+
{
30+
var document = context.Document;
31+
var diagnostic = context.Diagnostics[0];
32+
var root = await document.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
33+
var node = root.FindNode(context.Span, getInnermostNodeForTie: true);
34+
35+
if (node is not InvocationExpressionSyntax invocation)
36+
{
37+
return;
38+
}
39+
40+
context.RegisterCodeFix(
41+
CodeAction.Create(
42+
MicrosoftNetCoreAnalyzersResources.CollapseMultiplePathOperationsCodeFixTitle,
43+
createChangedDocument: cancellationToken => CollapsePathOperationAsync(document, invocation, cancellationToken),
44+
equivalenceKey: nameof(MicrosoftNetCoreAnalyzersResources.CollapseMultiplePathOperationsCodeFixTitle)),
45+
diagnostic);
46+
}
47+
48+
private static Task<Document> CollapsePathOperationAsync(Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken)
49+
{
50+
// Collect all arguments by recursively unwrapping nested Path.Combine/Join calls
51+
var allArguments = CollectAllArguments(invocation);
52+
53+
// Create new argument list with all collected arguments
54+
var newArgumentList = SyntaxFactory.ArgumentList(
55+
SyntaxFactory.SeparatedList(allArguments));
56+
57+
// Create the new invocation with all arguments
58+
var newInvocation = invocation.WithArgumentList(newArgumentList)
59+
.WithTriviaFrom(invocation);
60+
61+
var oldRoot = document.GetSyntaxRootAsync(cancellationToken).Result!;
62+
var newRoot = oldRoot.ReplaceNode(invocation, newInvocation);
63+
64+
return Task.FromResult(document.WithSyntaxRoot(newRoot));
65+
}
66+
67+
private static ArgumentSyntax[] CollectAllArguments(InvocationExpressionSyntax invocation)
68+
{
69+
var arguments = ImmutableArray.CreateBuilder<ArgumentSyntax>();
70+
71+
foreach (var argument in invocation.ArgumentList.Arguments)
72+
{
73+
if (argument.Expression is InvocationExpressionSyntax nestedInvocation &&
74+
IsPathCombineOrJoin(nestedInvocation, out var methodName) &&
75+
IsPathCombineOrJoin(invocation, out var outerMethodName) &&
76+
methodName == outerMethodName)
77+
{
78+
// Recursively collect arguments from nested invocation
79+
arguments.AddRange(CollectAllArguments(nestedInvocation));
80+
}
81+
else
82+
{
83+
arguments.Add(argument);
84+
}
85+
}
86+
87+
return arguments.ToArray();
88+
}
89+
90+
private static bool IsPathCombineOrJoin(InvocationExpressionSyntax invocation, out string methodName)
91+
{
92+
methodName = string.Empty;
93+
94+
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
95+
{
96+
var name = memberAccess.Name.Identifier.Text;
97+
if ((name == "Combine" || name == "Join") &&
98+
memberAccess.Expression is IdentifierNameSyntax identifier &&
99+
identifier.Identifier.Text == "Path")
100+
{
101+
methodName = name;
102+
return true;
103+
}
104+
105+
// Also handle fully qualified names like System.IO.Path
106+
if ((name == "Combine" || name == "Join") &&
107+
memberAccess.Expression.ToString().EndsWith("Path"))
108+
{
109+
methodName = name;
110+
return true;
111+
}
112+
}
113+
114+
return false;
115+
}
116+
}
117+
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,18 @@ In many situations, logging is disabled or set to a log level that results in an
19081908
|CodeFix|True|
19091909
---
19101910

1911+
## [CA1876](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876): Collapse consecutive Path.Combine or Path.Join operations
1912+
1913+
When multiple Path.Combine or Path.Join operations are nested, they can be collapsed into a single operation for better performance and readability.
1914+
1915+
|Item|Value|
1916+
|-|-|
1917+
|Category|Performance|
1918+
|Enabled|True|
1919+
|Severity|Info|
1920+
|CodeFix|True|
1921+
---
1922+
19111923
## [CA2000](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2000): Dispose objects before losing scope
19121924

19131925
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
@@ -3507,6 +3507,26 @@
35073507
]
35083508
}
35093509
},
3510+
"CA1876": {
3511+
"id": "CA1876",
3512+
"shortDescription": "Collapse consecutive Path.Combine or Path.Join operations",
3513+
"fullDescription": "When multiple Path.Combine or Path.Join operations are nested, they can be collapsed into a single operation for better performance and readability.",
3514+
"defaultLevel": "note",
3515+
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876",
3516+
"properties": {
3517+
"category": "Performance",
3518+
"isEnabledByDefault": true,
3519+
"typeName": "CollapseMultiplePathOperationsAnalyzer",
3520+
"languages": [
3521+
"C#",
3522+
"Visual Basic"
3523+
],
3524+
"tags": [
3525+
"Telemetry",
3526+
"EnabledRuleInAggressiveMode"
3527+
]
3528+
}
3529+
},
35103530
"CA2000": {
35113531
"id": "CA2000",
35123532
"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
@@ -7,6 +7,7 @@ Rule ID | Category | Severity | Notes
77
CA1873 | Performance | Info | AvoidPotentiallyExpensiveCallWhenLoggingAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1873)
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)
10+
CA1876 | Performance | Info | CollapseMultiplePathOperationsAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1876)
1011
CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2023)
1112
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
1213
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
@@ -2228,4 +2228,16 @@ Widening and user defined conversions are not supported with generic types.</val
22282228
<data name="DoNotUseThreadVolatileReadWriteCodeFixTitle" xml:space="preserve">
22292229
<value>Replace obsolete call</value>
22302230
</data>
2231+
<data name="CollapseMultiplePathOperationsTitle" xml:space="preserve">
2232+
<value>Collapse consecutive Path.Combine or Path.Join operations</value>
2233+
</data>
2234+
<data name="CollapseMultiplePathOperationsMessage" xml:space="preserve">
2235+
<value>Multiple consecutive Path.{0} operations can be collapsed into a single operation</value>
2236+
</data>
2237+
<data name="CollapseMultiplePathOperationsDescription" xml:space="preserve">
2238+
<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>
2239+
</data>
2240+
<data name="CollapseMultiplePathOperationsCodeFixTitle" xml:space="preserve">
2241+
<value>Collapse into single Path operation</value>
2242+
</data>
22312243
</root>

0 commit comments

Comments
 (0)