diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/FavorDirectoryEnumerationCalls.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/FavorDirectoryEnumerationCalls.cs new file mode 100644 index 0000000..225378f --- /dev/null +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.CodeFixes/FavorDirectoryEnumerationCalls.cs @@ -0,0 +1,207 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Text; + +namespace IntelliTect.Analyzer.CodeFixes +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FavorDirectoryEnumerationCalls))] + [Shared] + public class FavorDirectoryEnumerationCalls : CodeFixProvider + { + private const string TitleGetFiles = "Use Directory.EnumerateFiles"; + private const string TitleGetDirectories = "Use Directory.EnumerateDirectories"; + + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create( + Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId301, + Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId302); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + Diagnostic diagnostic = context.Diagnostics.First(); + TextSpan diagnosticSpan = diagnostic.Location.SourceSpan; + + // The diagnostic span covers the full invocation expression (Directory.GetFiles(...)) + InvocationExpressionSyntax invocation = root.FindToken(diagnosticSpan.Start) + .Parent.AncestorsAndSelf() + .OfType() + .First(); + + bool isGetFiles = diagnostic.Id == Analyzers.FavorDirectoryEnumerationCalls.DiagnosticId301; + string title = isGetFiles ? TitleGetFiles : TitleGetDirectories; + string newMethodName = isGetFiles ? "EnumerateFiles" : "EnumerateDirectories"; + + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedDocument: c => UseEnumerationMethodAsync(context.Document, invocation, newMethodName, c), + equivalenceKey: title), + diagnostic); + } + + private static async Task UseEnumerationMethodAsync( + Document document, + InvocationExpressionSyntax invocation, + string newMethodName, + CancellationToken cancellationToken) + { + var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; + + SemanticModel semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + // Rename: Directory.GetFiles(...) → Directory.EnumerateFiles(...) + InvocationExpressionSyntax renamedInvocation = invocation.WithExpression( + memberAccess.WithName(SyntaxFactory.IdentifierName(newMethodName))); + + ExpressionSyntax replacement = NeedsToArrayWrapper(invocation, semanticModel, cancellationToken) + // Wrap as Directory.EnumerateFiles(...).ToArray() + ? SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + renamedInvocation, + SyntaxFactory.IdentifierName("ToArray"))) + : renamedInvocation; + + SyntaxNode oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + SyntaxNode newRoot = oldRoot.ReplaceNode(invocation, replacement.WithAdditionalAnnotations(Formatter.Annotation)); + + if (replacement != renamedInvocation && newRoot is CompilationUnitSyntax compilationUnit) + { + newRoot = AddUsingIfMissing(compilationUnit, "System.Linq"); + } + + return document.WithSyntaxRoot(newRoot); + } + + private static bool NeedsToArrayWrapper( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken ct) + { + SyntaxNode parent = invocation.Parent; + + // string[] files = Directory.GetFiles(...) or field/property initializer + if (parent is EqualsValueClauseSyntax equalsValue) + { + // Local variable or field: string[] files = ... / private string[] _files = ... + if (equalsValue.Parent is VariableDeclaratorSyntax + && equalsValue.Parent.Parent is VariableDeclarationSyntax declaration + && semanticModel.GetTypeInfo(declaration.Type, ct).Type is IArrayTypeSymbol) + { + return true; + } + + // Property initializer: public string[] Files { get; } = Directory.GetFiles(...) + if (equalsValue.Parent is PropertyDeclarationSyntax property + && semanticModel.GetTypeInfo(property.Type, ct).Type is IArrayTypeSymbol) + { + return true; + } + } + + // files = Directory.GetFiles(...) + if (parent is AssignmentExpressionSyntax assignment + && semanticModel.GetTypeInfo(assignment.Left, ct).Type is IArrayTypeSymbol) + { + return true; + } + + // return Directory.GetFiles(...) in a method or local function returning string[] + if (parent is ReturnStatementSyntax) + { + TypeSyntax returnType = invocation.Ancestors() + .Select(a => a switch + { + MethodDeclarationSyntax m => m.ReturnType, + LocalFunctionStatementSyntax lf => lf.ReturnType, + _ => null + }) + .FirstOrDefault(t => t != null); + if (returnType != null + && semanticModel.GetTypeInfo(returnType, ct).Type is IArrayTypeSymbol) + { + return true; + } + } + + // Expression-bodied members: string[] GetFiles() => Directory.GetFiles(...) + if (parent is ArrowExpressionClauseSyntax arrow) + { + TypeSyntax returnType = arrow.Parent switch + { + MethodDeclarationSyntax m => m.ReturnType, + LocalFunctionStatementSyntax lf => lf.ReturnType, + PropertyDeclarationSyntax p => p.Type, + _ => null + }; + if (returnType != null && semanticModel.GetTypeInfo(returnType, ct).Type is IArrayTypeSymbol) + { + return true; + } + } + + // SomeMethod(Directory.GetFiles(...)) where the parameter type is string[] + if (parent is ArgumentSyntax argument + && argument.Parent is ArgumentListSyntax argumentList + && argumentList.Parent is InvocationExpressionSyntax outerInvocation + && semanticModel.GetSymbolInfo(outerInvocation, ct).Symbol is IMethodSymbol outerMethod) + { + IParameterSymbol targetParam; + + // Named argument: SomeMethod(param: Directory.GetFiles(...)) + if (argument.NameColon != null) + { + string paramName = argument.NameColon.Name.Identifier.Text; + targetParam = outerMethod.Parameters.FirstOrDefault(p => p.Name == paramName); + } + else + { + int argIndex = argumentList.Arguments.IndexOf(argument); + int paramCount = outerMethod.Parameters.Length; + targetParam = argIndex >= 0 && argIndex < paramCount + ? outerMethod.Parameters[argIndex] + : argIndex >= 0 && paramCount > 0 && outerMethod.Parameters[paramCount - 1].IsParams + ? outerMethod.Parameters[paramCount - 1] + : null; + } + + if (targetParam?.Type is IArrayTypeSymbol) + { + return true; + } + } + + return false; + } + + private static SyntaxNode AddUsingIfMissing(CompilationUnitSyntax root, string namespaceName) + { + bool alreadyPresent = root.Usings.Any(u => u.Name?.ToString() == namespaceName); + if (alreadyPresent) + { + return root; + } + + UsingDirectiveSyntax newUsing = SyntaxFactory.UsingDirective( + SyntaxFactory.ParseName(namespaceName)) + .NormalizeWhitespace() + .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + + return root.AddUsings(newUsing); + } + } +} diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs index 97af362..2c7e13e 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/FavorEnumeratorDirectoryCallsTests.cs @@ -1,4 +1,6 @@ -using Microsoft.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.VisualStudio.TestTools.UnitTesting; using TestHelper; @@ -74,7 +76,199 @@ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() return new Analyzers.FavorDirectoryEnumerationCalls(); } + protected override CodeFixProvider GetCSharpCodeFixProvider() + { + return new CodeFixes.FavorDirectoryEnumerationCalls(); + } + + [TestMethod] + public async Task GetFiles_AssignedToStringArray_CodeFix_WrapsWithToArray() + { + string source = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + string[] files = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory); + + foreach (string file in files) + { + Console.WriteLine($""File found: ${file}""); + } + } + } +}"; + string fixedSource = @"using System; +using System.IO; +using System.Linq; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + string[] files = Directory.EnumerateFiles(AppDomain.CurrentDomain.BaseDirectory).ToArray(); + + foreach (string file in files) + { + Console.WriteLine($""File found: ${file}""); + } + } + } +}"; + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task GetFiles_UsedInForeach_CodeFix_SimpleRename() + { + string source = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory)) + { + Console.WriteLine(file); + } + } + } +}"; + string fixedSource = @"using System; +using System.IO; +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + foreach (string file in Directory.EnumerateFiles(AppDomain.CurrentDomain.BaseDirectory)) + { + Console.WriteLine(file); + } + } + } +}"; + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task GetDirectories_AssignedToStringArray_CodeFix_WrapsWithToArray() + { + string source = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + string[] dirs = Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory); + + foreach (string dir in dirs) + { + Console.WriteLine($""Directory found: ${dir}""); + } + } + } +}"; + string fixedSource = @"using System; +using System.IO; +using System.Linq; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + string[] dirs = Directory.EnumerateDirectories(AppDomain.CurrentDomain.BaseDirectory).ToArray(); + + foreach (string dir in dirs) + { + Console.WriteLine($""Directory found: ${dir}""); + } + } + } +}"; + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task GetDirectories_UsedInForeach_CodeFix_SimpleRename() + { + string source = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + foreach (string dir in Directory.GetDirectories(AppDomain.CurrentDomain.BaseDirectory)) + { + Console.WriteLine(dir); + } + } + } +}"; + string fixedSource = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static void Main(string[] args) + { + foreach (string dir in Directory.EnumerateDirectories(AppDomain.CurrentDomain.BaseDirectory)) + { + Console.WriteLine(dir); + } + } + } +}"; + await VerifyCSharpFix(source, fixedSource); + } + + [TestMethod] + public async Task GetFiles_ExpressionBodiedMethod_CodeFix_WrapsWithToArray() + { + string source = @"using System; +using System.IO; + +namespace ConsoleApp5 +{ + class Program + { + static string[] GetAllFiles(string path) => Directory.GetFiles(path); + } +}"; + string fixedSource = @"using System; +using System.IO; +using System.Linq; + +namespace ConsoleApp5 +{ + class Program + { + static string[] GetAllFiles(string path) => Directory.EnumerateFiles(path).ToArray(); + } +}"; + await VerifyCSharpFix(source, fixedSource); + } [TestMethod] public void UsageOfDirectoryGetDirectories_ProducesInfoMessage() diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/Helpers/DiagnosticVerifier.Helper.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/Helpers/DiagnosticVerifier.Helper.cs index 555e025..b732b60 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/Helpers/DiagnosticVerifier.Helper.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/Helpers/DiagnosticVerifier.Helper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.IO; using System.Linq; using System.Linq.Expressions; using Microsoft.CodeAnalysis; @@ -21,6 +22,20 @@ public abstract partial class DiagnosticVerifier private static readonly MetadataReference _CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location); private static readonly MetadataReference _CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location); private static readonly MetadataReference _LinqExpressionsReference = MetadataReference.CreateFromFile(typeof(Expression<>).Assembly.Location); + private static readonly MetadataReference _SystemRuntimeReference = GetSystemRuntimeReference(); + + private static MetadataReference GetSystemRuntimeReference() + { + string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location); + if (string.IsNullOrEmpty(runtimeDir)) + { + return null; + } + string systemRuntimePath = Path.Join(runtimeDir, "System.Runtime.dll"); + return File.Exists(systemRuntimePath) + ? MetadataReference.CreateFromFile(systemRuntimePath) + : null; + } internal static string DefaultFilePathPrefix = "Test"; internal static string CSharpDefaultFileExt = "cs"; @@ -162,6 +177,11 @@ private static Project CreateProject(string[] sources, string language = Languag .AddMetadataReference(projectId, _CSharpSymbolsReference) .AddMetadataReference(projectId, _CodeAnalysisReference) .AddMetadataReference(projectId, _LinqExpressionsReference); + + if (_SystemRuntimeReference != null) + { + solution = solution.AddMetadataReference(projectId, _SystemRuntimeReference); + } } int count = 0; diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs index 9d4939f..29f22b4 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/FavorDirectoryEnumerationCalls.cs @@ -11,6 +11,9 @@ namespace IntelliTect.Analyzer.Analyzers [DiagnosticAnalyzer(LanguageNames.CSharp)] public class FavorDirectoryEnumerationCalls : DiagnosticAnalyzer { + public const string DiagnosticId301 = "INTL0301"; + public const string DiagnosticId302 = "INTL0302"; + private const string Category = "Performance"; private static readonly DiagnosticDescriptor _Rule301 = new(Rule301.DiagnosticId, @@ -84,13 +87,13 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) private static class Rule301 { - internal const string DiagnosticId = "INTL0301"; + internal const string DiagnosticId = FavorDirectoryEnumerationCalls.DiagnosticId301; internal const string Title = "Favor using EnumerateFiles"; internal const string MessageFormat = "Favor using the method `EnumerateFiles` over the `GetFiles` method"; #pragma warning disable INTL0001 // Allow field to not be prefixed with an underscore to match the style internal static readonly string HelpMessageUri = DiagnosticUrlBuilder.GetUrl(Title, DiagnosticId); -#pragma warning restore INTL0001 +#pragma warning restore INTL0001 internal const string Description = "When you use EnumerateFiles, you can start enumerating the collection of names before the whole collection is returned; when you use GetFiles, you must wait for the whole array of names to be returned before you can access the array. Therefore, when you are working with many files and directories, EnumerateFiles can be more efficient."; @@ -98,13 +101,13 @@ private static class Rule301 private static class Rule302 { - internal const string DiagnosticId = "INTL0302"; + internal const string DiagnosticId = FavorDirectoryEnumerationCalls.DiagnosticId302; internal const string Title = "Favor using EnumerateDirectories"; internal const string MessageFormat = "Favor using the method `EnumerateDirectories` over the `GetDirectories` method"; #pragma warning disable INTL0001 // Allow field to not be prefixed with an underscore to match the style internal static readonly string HelpMessageUri = DiagnosticUrlBuilder.GetUrl(Title, DiagnosticId); -#pragma warning restore INTL0001 +#pragma warning restore INTL0001 internal const string Description = "When you use EnumerateDirectories, you can start enumerating the collection of names before the whole collection is returned; when you use GetDirectories, you must wait for the whole array of names to be returned before you can access the array. Therefore, when you are working with many files and directories, EnumerateDirectories can be more efficient.";