Skip to content

Commit b51f4f8

Browse files
feat: Add code fix for INTL0301/INTL0302 (FavorDirectoryEnumerationCalls)
- Add public DiagnosticId301/302 constants to the analyzer (required by the code fix) - Add FavorDirectoryEnumerationCalls CodeFixProvider that renames GetFiles/GetDirectories to their lazy Enumerate* counterparts, wrapping with .ToArray() when the result is assigned to / returned as string[] - Add System.Runtime metadata reference to test helper to support LINQ-based code fixes - Add 4 code fix tests covering both rename and .ToArray() wrapping scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e888619 commit b51f4f8

4 files changed

Lines changed: 358 additions & 5 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
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.CodeAnalysis.Formatting;
12+
using Microsoft.CodeAnalysis.Text;
13+
14+
namespace IntelliTect.Analyzer.CodeFixes
15+
{
16+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FavorDirectoryEnumerationCalls))]
17+
[Shared]
18+
public class FavorDirectoryEnumerationCalls : CodeFixProvider
19+
{
20+
private const string TitleGetFiles = "Use Directory.EnumerateFiles";
21+
private const string TitleGetDirectories = "Use Directory.EnumerateDirectories";
22+
23+
public sealed override ImmutableArray<string> FixableDiagnosticIds =>
24+
ImmutableArray.Create(
25+
Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId301,
26+
Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId302);
27+
28+
public sealed override FixAllProvider GetFixAllProvider() =>
29+
WellKnownFixAllProviders.BatchFixer;
30+
31+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
32+
{
33+
SyntaxNode root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
34+
35+
Diagnostic diagnostic = context.Diagnostics.First();
36+
TextSpan diagnosticSpan = diagnostic.Location.SourceSpan;
37+
38+
// The diagnostic span covers the full invocation expression (Directory.GetFiles(...))
39+
InvocationExpressionSyntax invocation = root.FindToken(diagnosticSpan.Start)
40+
.Parent.AncestorsAndSelf()
41+
.OfType<InvocationExpressionSyntax>()
42+
.First();
43+
44+
bool isGetFiles = diagnostic.Id == Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId301;
45+
string title = isGetFiles ? TitleGetFiles : TitleGetDirectories;
46+
string newMethodName = isGetFiles ? "EnumerateFiles" : "EnumerateDirectories";
47+
48+
context.RegisterCodeFix(
49+
CodeAction.Create(
50+
title: title,
51+
createChangedDocument: c => UseEnumerationMethodAsync(context.Document, invocation, newMethodName, c),
52+
equivalenceKey: title),
53+
diagnostic);
54+
}
55+
56+
private static async Task<Document> UseEnumerationMethodAsync(
57+
Document document,
58+
InvocationExpressionSyntax invocation,
59+
string newMethodName,
60+
CancellationToken cancellationToken)
61+
{
62+
var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
63+
64+
SemanticModel semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
65+
66+
// Rename: Directory.GetFiles(...) → Directory.EnumerateFiles(...)
67+
InvocationExpressionSyntax renamedInvocation = invocation.WithExpression(
68+
memberAccess.WithName(SyntaxFactory.IdentifierName(newMethodName)));
69+
70+
ExpressionSyntax replacement;
71+
if (NeedsToArrayWrapper(invocation, semanticModel, cancellationToken))
72+
{
73+
// Wrap as Directory.EnumerateFiles(...).ToArray()
74+
replacement = SyntaxFactory.InvocationExpression(
75+
SyntaxFactory.MemberAccessExpression(
76+
SyntaxKind.SimpleMemberAccessExpression,
77+
renamedInvocation,
78+
SyntaxFactory.IdentifierName("ToArray")));
79+
}
80+
else
81+
{
82+
replacement = renamedInvocation;
83+
}
84+
85+
SyntaxNode oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
86+
SyntaxNode newRoot = oldRoot.ReplaceNode(invocation, replacement.WithAdditionalAnnotations(Formatter.Annotation));
87+
88+
if (replacement != renamedInvocation && newRoot is CompilationUnitSyntax compilationUnit)
89+
{
90+
newRoot = AddUsingIfMissing(compilationUnit, "System.Linq");
91+
}
92+
93+
return document.WithSyntaxRoot(newRoot);
94+
}
95+
96+
private static bool NeedsToArrayWrapper(
97+
InvocationExpressionSyntax invocation,
98+
SemanticModel semanticModel,
99+
CancellationToken ct)
100+
{
101+
SyntaxNode parent = invocation.Parent;
102+
103+
// string[] files = Directory.GetFiles(...)
104+
if (parent is EqualsValueClauseSyntax equalsValue
105+
&& equalsValue.Parent is VariableDeclaratorSyntax
106+
&& equalsValue.Parent.Parent is VariableDeclarationSyntax declaration
107+
&& semanticModel.GetTypeInfo(declaration.Type, ct).Type is IArrayTypeSymbol)
108+
{
109+
return true;
110+
}
111+
112+
// files = Directory.GetFiles(...)
113+
if (parent is AssignmentExpressionSyntax assignment
114+
&& semanticModel.GetTypeInfo(assignment.Left, ct).Type is IArrayTypeSymbol)
115+
{
116+
return true;
117+
}
118+
119+
// return Directory.GetFiles(...) in a method returning string[]
120+
if (parent is ReturnStatementSyntax)
121+
{
122+
MethodDeclarationSyntax enclosingMethod = invocation.Ancestors()
123+
.OfType<MethodDeclarationSyntax>()
124+
.FirstOrDefault();
125+
if (enclosingMethod != null
126+
&& semanticModel.GetTypeInfo(enclosingMethod.ReturnType, ct).Type is IArrayTypeSymbol)
127+
{
128+
return true;
129+
}
130+
}
131+
132+
// SomeMethod(Directory.GetFiles(...)) where the parameter type is string[]
133+
if (parent is ArgumentSyntax argument
134+
&& argument.Parent is ArgumentListSyntax argumentList
135+
&& argumentList.Parent is InvocationExpressionSyntax outerInvocation
136+
&& semanticModel.GetSymbolInfo(outerInvocation, ct).Symbol is IMethodSymbol outerMethod)
137+
{
138+
int argIndex = argumentList.Arguments.IndexOf(argument);
139+
if (argIndex >= 0 && argIndex < outerMethod.Parameters.Length
140+
&& outerMethod.Parameters[argIndex].Type is IArrayTypeSymbol)
141+
{
142+
return true;
143+
}
144+
}
145+
146+
return false;
147+
}
148+
149+
private static SyntaxNode AddUsingIfMissing(CompilationUnitSyntax root, string namespaceName)
150+
{
151+
bool alreadyPresent = root.Usings.Any(u => u.Name?.ToString() == namespaceName);
152+
if (alreadyPresent)
153+
{
154+
return root;
155+
}
156+
157+
UsingDirectiveSyntax newUsing = SyntaxFactory.UsingDirective(
158+
SyntaxFactory.ParseName(namespaceName))
159+
.NormalizeWhitespace()
160+
.WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
161+
162+
return root.AddUsings(newUsing);
163+
}
164+
}
165+
}

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using Microsoft.CodeAnalysis;
1+
using System.Threading.Tasks;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CodeFixes;
24
using Microsoft.CodeAnalysis.Diagnostics;
35
using Microsoft.VisualStudio.TestTools.UnitTesting;
46
using TestHelper;
@@ -74,6 +76,173 @@ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
7476
return new Analyzers.FavorDirectoryEnumerationCalls();
7577
}
7678

79+
protected override CodeFixProvider GetCSharpCodeFixProvider()
80+
{
81+
return new CodeFixes.FavorDirectoryEnumerationCalls();
82+
}
83+
84+
[TestMethod]
85+
public async Task GetFiles_AssignedToStringArray_CodeFix_WrapsWithToArray()
86+
{
87+
string source = @"using System;
88+
using System.IO;
89+
90+
namespace ConsoleApp5
91+
{
92+
class Program
93+
{
94+
static void Main(string[] args)
95+
{
96+
string[] files = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory);
97+
98+
foreach (string file in files)
99+
{
100+
Console.WriteLine($""File found: ${file}"");
101+
}
102+
}
103+
}
104+
}";
105+
string fixedSource = @"using System;
106+
using System.IO;
107+
using System.Linq;
108+
109+
namespace ConsoleApp5
110+
{
111+
class Program
112+
{
113+
static void Main(string[] args)
114+
{
115+
string[] files = Directory.EnumerateFiles(AppDomain.CurrentDomain.BaseDirectory).ToArray();
116+
117+
foreach (string file in files)
118+
{
119+
Console.WriteLine($""File found: ${file}"");
120+
}
121+
}
122+
}
123+
}";
124+
await VerifyCSharpFix(source, fixedSource);
125+
}
126+
127+
[TestMethod]
128+
public async Task GetFiles_UsedInForeach_CodeFix_SimpleRename()
129+
{
130+
string source = @"using System;
131+
using System.IO;
132+
133+
namespace ConsoleApp5
134+
{
135+
class Program
136+
{
137+
static void Main(string[] args)
138+
{
139+
foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory))
140+
{
141+
Console.WriteLine(file);
142+
}
143+
}
144+
}
145+
}";
146+
string fixedSource = @"using System;
147+
using System.IO;
148+
149+
namespace ConsoleApp5
150+
{
151+
class Program
152+
{
153+
static void Main(string[] args)
154+
{
155+
foreach (string file in Directory.EnumerateFiles(AppDomain.CurrentDomain.BaseDirectory))
156+
{
157+
Console.WriteLine(file);
158+
}
159+
}
160+
}
161+
}";
162+
await VerifyCSharpFix(source, fixedSource);
163+
}
164+
165+
[TestMethod]
166+
public async Task GetDirectories_AssignedToStringArray_CodeFix_WrapsWithToArray()
167+
{
168+
string source = @"using System;
169+
using System.IO;
170+
171+
namespace ConsoleApp5
172+
{
173+
class Program
174+
{
175+
static void Main(string[] args)
176+
{
177+
string[] dirs = Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory);
178+
179+
foreach (string dir in dirs)
180+
{
181+
Console.WriteLine($""Directory found: ${dir}"");
182+
}
183+
}
184+
}
185+
}";
186+
string fixedSource = @"using System;
187+
using System.IO;
188+
using System.Linq;
189+
190+
namespace ConsoleApp5
191+
{
192+
class Program
193+
{
194+
static void Main(string[] args)
195+
{
196+
string[] dirs = Directory.EnumerateDirectories(AppDomain.CurrentDomain.BaseDirectory).ToArray();
197+
198+
foreach (string dir in dirs)
199+
{
200+
Console.WriteLine($""Directory found: ${dir}"");
201+
}
202+
}
203+
}
204+
}";
205+
await VerifyCSharpFix(source, fixedSource);
206+
}
207+
208+
[TestMethod]
209+
public async Task GetDirectories_UsedInForeach_CodeFix_SimpleRename()
210+
{
211+
string source = @"using System;
212+
using System.IO;
213+
214+
namespace ConsoleApp5
215+
{
216+
class Program
217+
{
218+
static void Main(string[] args)
219+
{
220+
foreach (string dir in Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory))
221+
{
222+
Console.WriteLine(dir);
223+
}
224+
}
225+
}
226+
}";
227+
string fixedSource = @"using System;
228+
using System.IO;
229+
230+
namespace ConsoleApp5
231+
{
232+
class Program
233+
{
234+
static void Main(string[] args)
235+
{
236+
foreach (string dir in Directory.EnumerateDirectories(AppDomain.CurrentDomain.BaseDirectory))
237+
{
238+
Console.WriteLine(dir);
239+
}
240+
}
241+
}
242+
}";
243+
await VerifyCSharpFix(source, fixedSource);
244+
}
245+
77246

78247

79248
[TestMethod]

IntelliTect.Analyzer/IntelliTect.Analyzer.Test/Helpers/DiagnosticVerifier.Helper.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Collections.Immutable;
4+
using System.IO;
45
using System.Linq;
56
using System.Linq.Expressions;
67
using Microsoft.CodeAnalysis;
@@ -21,6 +22,16 @@ public abstract partial class DiagnosticVerifier
2122
private static readonly MetadataReference _CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location);
2223
private static readonly MetadataReference _CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location);
2324
private static readonly MetadataReference _LinqExpressionsReference = MetadataReference.CreateFromFile(typeof(Expression<>).Assembly.Location);
25+
private static readonly MetadataReference _SystemRuntimeReference = GetSystemRuntimeReference();
26+
27+
private static MetadataReference GetSystemRuntimeReference()
28+
{
29+
string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
30+
string systemRuntimePath = Path.Combine(runtimeDir, "System.Runtime.dll");
31+
return File.Exists(systemRuntimePath)
32+
? MetadataReference.CreateFromFile(systemRuntimePath)
33+
: null;
34+
}
2435

2536
internal static string DefaultFilePathPrefix = "Test";
2637
internal static string CSharpDefaultFileExt = "cs";
@@ -162,6 +173,11 @@ private static Project CreateProject(string[] sources, string language = Languag
162173
.AddMetadataReference(projectId, _CSharpSymbolsReference)
163174
.AddMetadataReference(projectId, _CodeAnalysisReference)
164175
.AddMetadataReference(projectId, _LinqExpressionsReference);
176+
177+
if (_SystemRuntimeReference != null)
178+
{
179+
solution = solution.AddMetadataReference(projectId, _SystemRuntimeReference);
180+
}
165181
}
166182

167183
int count = 0;

0 commit comments

Comments
 (0)