diff --git a/src/libraries/FlashOWare.Tool.Cli/CliApplication.UsingDirectives.cs b/src/libraries/FlashOWare.Tool.Cli/CliApplication.UsingDirectives.cs index bebcb46..0f7fcf1 100644 --- a/src/libraries/FlashOWare.Tool.Cli/CliApplication.UsingDirectives.cs +++ b/src/libraries/FlashOWare.Tool.Cli/CliApplication.UsingDirectives.cs @@ -14,7 +14,7 @@ private static void AddUsingCommand(RootCommand rootCommand, MSBuildWorkspace wo { var usingCommand = new Command("using", " Analyze or refactor C# using directives."); var countCommand = new Command("count", "Count and list the top-level using directives of a C# project."); - var globalizeCommand = new Command("globalize", "Move a top-level using directive to a global using directive in a C# project."); + var globalizeCommand = new Command("globalize", "Move top-level using directives to global using directives in a C# project."); var projectOption = new Option(new[] { "--project", "--proj" }, "The path to the project file to operate on (defaults to the current directory if there is only one project).") .ExistingOnly(); @@ -42,12 +42,14 @@ private static void AddUsingCommand(RootCommand rootCommand, MSBuildWorkspace wo await CountUsingsAsync(workspace, project.FullName, usings.ToImmutableArray(), context.Console, context.GetCancellationToken()); }); - var usingArgument = new Argument("USING", "The name of the top-level using directive to convert to a global using directive."); - globalizeCommand.Add(usingArgument); + var globalizeArgument = new Argument("USINGS", "The names of the top-level using directives to convert to global using directives. If usings are not specified, the command will globalize all top-level directives."); + var forceOption = new Option("--force", "Forces all top-level using directives to be globalized when no usings are specified."); + globalizeCommand.Add(globalizeArgument); globalizeCommand.Add(projectOption); + globalizeCommand.Add(forceOption); globalizeCommand.SetHandler(async (InvocationContext context) => { - string localUsing = context.ParseResult.GetValueForArgument(usingArgument); + string[] usings = context.ParseResult.GetValueForArgument(globalizeArgument); FileInfo? project = context.ParseResult.GetValueForOption(projectOption); if (project is null) { @@ -62,7 +64,13 @@ private static void AddUsingCommand(RootCommand rootCommand, MSBuildWorkspace wo }; } - await GlobalizeUsingsAsync(workspace, project.FullName, localUsing, context.Console, context.GetCancellationToken()); + bool isForced = context.ParseResult.GetValueForOption(forceOption); + if (usings.Length == 0 && !isForced) + { + throw new InvalidOperationException("No usings specified. To globalize all top-level using directives, run the command with '--force' option."); + } + + await GlobalizeUsingsAsync(workspace, project.FullName, usings.ToImmutableArray(), context.Console, context.GetCancellationToken()); }); usingCommand.Add(countCommand); @@ -94,7 +102,7 @@ private static async Task CountUsingsAsync(MSBuildWorkspace workspace, string pr } } - private static async Task GlobalizeUsingsAsync(MSBuildWorkspace workspace, string projectFilePath, string localUsing, IConsole console, CancellationToken cancellationToken) + private static async Task GlobalizeUsingsAsync(MSBuildWorkspace workspace, string projectFilePath, ImmutableArray usings, IConsole console, CancellationToken cancellationToken) { try { @@ -102,7 +110,7 @@ private static async Task GlobalizeUsingsAsync(MSBuildWorkspace workspace, strin Project project = await workspace.OpenProjectAsync(projectFilePath, null, cancellationToken); workspace.ThrowIfCannotApplyChanges(ApplyChangesKind.AddDocument, ApplyChangesKind.ChangeDocument); - var result = await UsingGlobalizer.GlobalizeAsync(project, localUsing, cancellationToken); + var result = await UsingGlobalizer.GlobalizeAsync(project, usings, cancellationToken); string? oldProject = null; if (project.DocumentIds.Count < result.Project.DocumentIds.Count) @@ -121,17 +129,26 @@ private static async Task GlobalizeUsingsAsync(MSBuildWorkspace workspace, strin console.WriteLine($"{nameof(Project)}: {result.Project.Name}"); - if (result.Using.Occurrences == 0) + if (result.Occurrences == 0) { - console.WriteLine($"""No occurrences of Using Directive "{localUsing}" were globalized."""); + string message = result.Usings.Count == 1 + ? $"""No occurrences of Using Directive "{result.Usings.Single().Name}" were globalized.""" + : $"""No occurrences of {result.Usings.Count} Using Directives were globalized."""; + console.WriteLine(message); } - else if (result.Using.Occurrences == 1) + else if (result.Occurrences == 1) { - console.WriteLine($"""1 occurrence of Using Directive "{localUsing}" was globalized to "{result.TargetDocument}"."""); + string message = result.Usings.Count == 1 + ? $"""1 occurrence of Using Directive "{result.Usings.Single().Name}" was globalized to "{result.TargetDocument}".""" + : $"""1 occurrence of {result.Usings.Count} Using Directives was globalized to "{result.TargetDocument}"."""; + console.WriteLine(message); } else { - console.WriteLine($"""{result.Using.Occurrences} occurrences of Using Directive "{localUsing}" were globalized to "{result.TargetDocument}"."""); + string message = result.Usings.Count == 1 + ? $"""{result.Usings.Single().Occurrences} occurrences of Using Directive "{result.Usings.Single().Name}" were globalized to "{result.TargetDocument}".""" + : $"""{result.Occurrences} occurrences of {result.Usings.Count} Using Directives were globalized to "{result.TargetDocument}"."""; + console.WriteLine(message); } } catch (OperationCanceledException) diff --git a/src/libraries/FlashOWare.Tool.Core/CodeAnalysis/CSharpSyntaxFactory.cs b/src/libraries/FlashOWare.Tool.Core/CodeAnalysis/CSharpSyntaxFactory.cs index af0ac8b..52e136f 100644 --- a/src/libraries/FlashOWare.Tool.Core/CodeAnalysis/CSharpSyntaxFactory.cs +++ b/src/libraries/FlashOWare.Tool.Core/CodeAnalysis/CSharpSyntaxFactory.cs @@ -7,7 +7,7 @@ namespace FlashOWare.Tool.Core.CodeAnalysis; -internal static class CSharpSyntaxFactory +internal static partial class CSharpSyntaxFactory { public static CompilationUnitSyntax GlobalUsingDirectiveRoot(string name, DocumentOptionSet options) { @@ -16,11 +16,23 @@ public static CompilationUnitSyntax GlobalUsingDirectiveRoot(string name, Docume .WithEndOfFileToken(Token(SyntaxKind.EndOfFileToken)); } + public static CompilationUnitSyntax GlobalUsingDirectivesRoot(IEnumerable names, DocumentOptionSet options) + { + return CompilationUnit() + .WithUsings(GlobalUsingDirectiveList(names, options)) + .WithEndOfFileToken(Token(SyntaxKind.EndOfFileToken)); + } + private static SyntaxList GlobalUsingDirectiveList(string name, DocumentOptionSet options) { return SingletonList(GlobalUsingDirective(name, options)); } + private static SyntaxList GlobalUsingDirectiveList(IEnumerable names, DocumentOptionSet options) + { + return List(GlobalUsingDirectives(names, options)); + } + public static UsingDirectiveSyntax GlobalUsingDirective(string name, DocumentOptionSet options) { return UsingDirective(IdentifierName(name)) @@ -29,6 +41,17 @@ public static UsingDirectiveSyntax GlobalUsingDirective(string name, DocumentOpt .WithSemicolonToken(Token(TriviaList(), SyntaxKind.SemicolonToken, EndOfLineList(options))); } + public static IEnumerable GlobalUsingDirectives(IEnumerable names, DocumentOptionSet options) + { + foreach (var name in names) + { + yield return GlobalUsingDirective(name, options); + } + } +} + +internal static partial class CSharpSyntaxFactory +{ private static SyntaxTriviaList EndOfLineList(DocumentOptionSet options) { return TriviaList(EndOfLine(options)); diff --git a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingCountResult.cs b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingCountResult.cs index 3b7628b..ad57b4b 100644 --- a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingCountResult.cs +++ b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingCountResult.cs @@ -36,14 +36,14 @@ internal void AddRange(ImmutableArray identifiers) internal void Increment(string identifier) { UsingDirective usingDirective = _usings[identifier]; - usingDirective.Occurrences++; + usingDirective.IncrementOccurrences(); } internal void IncrementOrAdd(string identifier) { if (_usings.TryGetValue(identifier, out UsingDirective? usingDirective)) { - usingDirective.Occurrences++; + usingDirective.IncrementOccurrences(); } else { diff --git a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingDirective.cs b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingDirective.cs index a208934..ff0f383 100644 --- a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingDirective.cs +++ b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingDirective.cs @@ -22,7 +22,12 @@ public UsingDirective(string name, int occurrences) } public required string Name { get; init; } - public int Occurrences { get; internal set; } + public int Occurrences { get; private set; } + + internal void IncrementOccurrences() + { + Occurrences++; + } public override string ToString() { diff --git a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizationResult.cs b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizationResult.cs index 3645235..1c1c538 100644 --- a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizationResult.cs +++ b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizationResult.cs @@ -1,23 +1,79 @@ using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace FlashOWare.Tool.Core.UsingDirectives; public sealed class UsingGlobalizationResult { - internal UsingGlobalizationResult() + private readonly Dictionary _usings = new(); + + internal UsingGlobalizationResult(Project project) { + Project = project; } [SetsRequiredMembers] - internal UsingGlobalizationResult(Project project, UsingDirective @using, string targetDocument) + internal UsingGlobalizationResult(Project project, string targetDocument) { Project = project; - Using = @using; TargetDocument = targetDocument; } - public required Project Project { get; init; } - public required UsingDirective Using { get; init; } + public Project Project { get; private set; } + public IReadOnlyCollection Usings => _usings.Values; public required string TargetDocument { get; init; } + public int Occurrences { get; private set; } + + internal void Initialize(ImmutableArray identifiers) + { + Debug.Assert(Occurrences == 0, $"Result has already been updated."); + Debug.Assert(_usings.Count == 0, $"Result has already been initialized."); + + foreach (string identifier in identifiers) + { + _ = _usings.TryAdd(identifier, new UsingDirective(identifier)); + } + } + + internal void Update(Project project) + { + Project = project; + } + + internal void Update(string identifier) + { + if (_usings.TryGetValue(identifier, out UsingDirective? usingDirective)) + { + usingDirective.IncrementOccurrences(); + } + else + { + usingDirective = new UsingDirective(identifier, 1); + _usings.Add(identifier, usingDirective); + } + + Occurrences++; + } + + internal void Update(string[] identifiers) + { + foreach (var identifier in identifiers) + { + Update(identifier); + } + } + + [Conditional("DEBUG")] + internal void Verify() + { + int occurrences = _usings.Values.Aggregate(0, static (sum, directive) => sum + directive.Occurrences); + Debug.Assert(occurrences == Occurrences, $"Verification of {nameof(UsingGlobalizationResult)} failed: {nameof(Occurrences)}={Occurrences} Aggregate={occurrences}"); + } + + public override string ToString() + { + return Project.Name; + } } diff --git a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizer.cs b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizer.cs index c322b2c..cf091d7 100644 --- a/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizer.cs +++ b/src/libraries/FlashOWare.Tool.Core/UsingDirectives/UsingGlobalizer.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; using System.Diagnostics; namespace FlashOWare.Tool.Core.UsingDirectives; @@ -10,7 +11,17 @@ public static class UsingGlobalizer { private const string DefaultTargetDocument = "GlobalUsings.cs"; - public static async Task GlobalizeAsync(Project project, string localUsing, CancellationToken cancellationToken = default) + public static Task GlobalizeAsync(Project project, CancellationToken cancellationToken = default) + { + return GlobalizeAsync(project, ImmutableArray.Empty, cancellationToken); + } + + public static Task GlobalizeAsync(Project project, string localUsing, CancellationToken cancellationToken = default) + { + return GlobalizeAsync(project, ImmutableArray.Create(localUsing), cancellationToken); + } + + public static async Task GlobalizeAsync(Project project, ImmutableArray usings, CancellationToken cancellationToken = default) { RoslynUtilities.ThrowIfNotCSharp(project, LanguageVersion.CSharp10, LanguageFeatures.GlobalUsingDirective); @@ -20,11 +31,12 @@ public static async Task GlobalizeAsync(Project projec throw new InvalidOperationException($"{nameof(Project)}.{nameof(Project.SupportsCompilation)} = {project.SupportsCompilation} ({project.Name})"); } - var usingDirective = new UsingDirective(localUsing); + var result = new UsingGlobalizationResult(project, DefaultTargetDocument); + result.Initialize(usings); if (RoslynUtilities.IsGeneratedCode(compilation)) { - return new UsingGlobalizationResult(project, usingDirective, DefaultTargetDocument); + return result; } var solution = project.Solution; @@ -53,64 +65,76 @@ public static async Task GlobalizeAsync(Project projec cancellationToken.ThrowIfCancellationRequested(); - solution = await GlobalizeAsync(solution, project.Id, document, compilationUnit, localUsing, usingDirective, cancellationToken); + solution = await GlobalizeAsync(solution, project.Id, document, compilationUnit, usings, result, cancellationToken); } var newProject = solution.GetProject(project.Id); Debug.Assert(newProject is not null, $"{nameof(ProjectId)} is not a {nameof(ProjectId)} of a {nameof(Project)} that is part of this {nameof(Solution)}."); - return new UsingGlobalizationResult(newProject, usingDirective, DefaultTargetDocument); + result.Update(newProject); + + result.Verify(); + return result; } - private static async Task GlobalizeAsync(Solution solution, ProjectId projectId, Document document, CompilationUnitSyntax compilationUnit, string localUsing, UsingDirective result, CancellationToken cancellationToken) + private static async Task GlobalizeAsync(Solution solution, ProjectId projectId, Document document, CompilationUnitSyntax compilationUnit, ImmutableArray usings, UsingGlobalizationResult result, CancellationToken cancellationToken) { - var options = await document.GetOptionsAsync(cancellationToken); + var usingNodes = compilationUnit.Usings.Where(IsLocalUsing); + if (usings.Length != 0) + { + usingNodes = usingNodes.Where(usingNode => usings.Contains(usingNode.Name.ToString())); + } - foreach (UsingDirectiveSyntax usingNode in compilationUnit.Usings) + UsingDirectiveSyntax[] globalizedNodes = usingNodes.ToArray(); + if (globalizedNodes.Length == 0) { - if (usingNode.Alias is not null || - !usingNode.StaticKeyword.IsKind(SyntaxKind.None) || - !usingNode.GlobalKeyword.IsKind(SyntaxKind.None)) + return solution; + } + string[] globalizedIdentifiers = globalizedNodes.Select(static usingNode => usingNode.Name.ToString()).ToArray(); + + var newRoot = compilationUnit.RemoveNodes(globalizedNodes, SyntaxRemoveOptions.KeepLeadingTrivia); + Debug.Assert(newRoot is not null, "The root node itself is removed."); + + result.Update(globalizedIdentifiers); + solution = solution.WithDocumentSyntaxRoot(document.Id, newRoot); + + Project? project = solution.GetProject(projectId); + Debug.Assert(project is not null, $"{nameof(ProjectId)} is not a {nameof(ProjectId)} of a {nameof(Project)} that is part of this {nameof(Solution)}."); + + if (project.Documents.SingleOrDefault(static document => document.Name == DefaultTargetDocument && document.Folders.Count == 0) is { } globalUsings) + { + SyntaxNode? globalUsingsSyntaxRoot = await globalUsings.GetSyntaxRootAsync(cancellationToken); + if (globalUsingsSyntaxRoot is null) { - continue; + throw new InvalidOperationException($"{nameof(Document)}.{nameof(Document.SupportsSyntaxTree)} = {globalUsings.SupportsSyntaxTree} ({globalUsings.Name})"); } - if (usingNode.Name.ToString() == localUsing) + var globalUsingsCompilationUnit = (CompilationUnitSyntax)globalUsingsSyntaxRoot; + var existingUsings = globalUsingsCompilationUnit.Usings.Select(static usingDirective => usingDirective.Name.ToString()); + string[] addedUsings = globalizedIdentifiers.Except(existingUsings, StringComparer.Ordinal).ToArray(); + if (addedUsings.Length != 0) { - var newRoot = compilationUnit.RemoveNode(usingNode, SyntaxRemoveOptions.KeepLeadingTrivia); - Debug.Assert(newRoot is not null, "The root node itself is removed."); - - solution = solution.WithDocumentSyntaxRoot(document.Id, newRoot); - - Project? project = solution.GetProject(projectId); - Debug.Assert(project is not null, $"{nameof(ProjectId)} is not a {nameof(ProjectId)} of a {nameof(Project)} that is part of this {nameof(Solution)}."); - - if (project.Documents.SingleOrDefault(static document => document.Name == DefaultTargetDocument && document.Folders.Count == 0) is { } globalUsings) - { - SyntaxNode? globalUsingsSyntaxRoot = await globalUsings.GetSyntaxRootAsync(cancellationToken); - if (globalUsingsSyntaxRoot is null) - { - throw new InvalidOperationException($"{nameof(Document)}.{nameof(Document.SupportsSyntaxTree)} = {globalUsings.SupportsSyntaxTree} ({globalUsings.Name})"); - } - - var globalUsingsCompilationUnit = (CompilationUnitSyntax)globalUsingsSyntaxRoot; - if (!globalUsingsCompilationUnit.Usings.Any(usingDirective => usingDirective.Name.ToString() == localUsing)) - { - var node = CSharpSyntaxFactory.GlobalUsingDirective(localUsing, options); - var newUsings = globalUsingsCompilationUnit.Usings.Add(node); - var newGlobalUsingsRoot = globalUsingsCompilationUnit.WithUsings(newUsings); - solution = solution.WithDocumentSyntaxRoot(globalUsings.Id, newGlobalUsingsRoot); - } - } - else - { - var syntaxRoot = CSharpSyntaxFactory.GlobalUsingDirectiveRoot(localUsing, options); - solution = solution.AddDocument(DocumentId.CreateNewId(projectId), DefaultTargetDocument, syntaxRoot); - } - - result.Occurrences++; + var options = await document.GetOptionsAsync(cancellationToken); + var nodes = CSharpSyntaxFactory.GlobalUsingDirectives(addedUsings, options); + var newUsings = globalUsingsCompilationUnit.Usings.AddRange(nodes); + var newGlobalUsingsRoot = globalUsingsCompilationUnit.WithUsings(newUsings); + solution = solution.WithDocumentSyntaxRoot(globalUsings.Id, newGlobalUsingsRoot); } } + else + { + var options = await document.GetOptionsAsync(cancellationToken); + IEnumerable addedUsings = globalizedIdentifiers.Distinct(StringComparer.Ordinal); + var syntaxRoot = CSharpSyntaxFactory.GlobalUsingDirectivesRoot(addedUsings, options); + solution = solution.AddDocument(DocumentId.CreateNewId(projectId), DefaultTargetDocument, syntaxRoot); + } return solution; } + + private static bool IsLocalUsing(UsingDirectiveSyntax usingNode) + { + return usingNode.Alias is null + && usingNode.StaticKeyword.IsKind(SyntaxKind.None) + && usingNode.GlobalKeyword.IsKind(SyntaxKind.None); + } } diff --git a/src/tests/FlashOWare.Tool.Cli.Tests/UsingDirectives/UsingGlobalizerTests.cs b/src/tests/FlashOWare.Tool.Cli.Tests/UsingDirectives/UsingGlobalizerTests.cs index 9c6f446..b760d12 100644 --- a/src/tests/FlashOWare.Tool.Cli.Tests/UsingDirectives/UsingGlobalizerTests.cs +++ b/src/tests/FlashOWare.Tool.Cli.Tests/UsingDirectives/UsingGlobalizerTests.cs @@ -60,7 +60,7 @@ internal class MyClass2 //Assert Console.Verify($""" Project: {Names.Project} - 2 occurrences of Using Directive "System" were globalized to "GlobalUsings.cs". + 2 occurrences of Using Directive "{Usings.System}" were globalized to "GlobalUsings.cs". """); Workspace.CreateExpectation() .AppendFile(""" @@ -174,7 +174,7 @@ internal class MyClass2 //Assert Console.Verify($""" Project: {Names.Project} - 2 occurrences of Using Directive "System" were globalized to "GlobalUsings.cs". + 2 occurrences of Using Directive "{Usings.System}" were globalized to "GlobalUsings.cs". """); string[] files = { Names.GlobalUsings, "MyClass1.cs", "MyClass2.cs", Path.Combine(Names.Properties, Names.AssemblyInfo), Path.Combine(Names.Properties, Names.GlobalUsings) }; Workspace.CreateExpectation() @@ -237,6 +237,192 @@ internal class MyClass2 Result.Verify(ExitCodes.Success); } + [Fact] + public async Task Globalize_NoSpecificUsingsWithForce_ReplacesAllUsingDirectives() + { + //Arrange + var project = Workspace.CreateProject() + .AddDocument(""" + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore + { + internal class MyClass1 + { + } + } + """, "MyClass1") + .AddDocument(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore; + + internal class MyClass2 + { + } + """, "MyClass2") + .Initialize(ProjectKind.SdkStyle, TargetFramework.Net60, LanguageVersion.CSharp10); + string[] args = new[] { "using", "globalize", "--project", project.File.FullName, "--force" }; + //Act + await RunAsync(args); + //Assert + Console.Verify($""" + Project: {Names.Project} + 13 occurrences of 8 Using Directives were globalized to "GlobalUsings.cs". + """); + Workspace.CreateExpectation() + .AppendFile(""" + global using System; + global using System.Collections.Generic; + global using System.IO; + global using System.Linq; + global using System.Net.Http; + global using System.Text; + global using System.Threading; + global using System.Threading.Tasks; + + """, Names.GlobalUsings) + .AppendFile(""" + + namespace ProjectUnderTest.NetCore + { + internal class MyClass1 + { + } + } + """, "MyClass1.cs") + .AppendFile(""" + + namespace ProjectUnderTest.NetCore; + + internal class MyClass2 + { + } + """, "MyClass2.cs") + .AppendFile(ProjectText.Create(TargetFramework.Net60, LanguageVersion.CSharp10), Names.CSharpProject) + .Verify(); + Result.Verify(ExitCodes.Success); + } + + [Fact] + public async Task Globalize_NoSpecificUsingsWithoutForce_ForceIsRequired() + { + //Arrange + var project = Workspace.CreateProject() + .AddDocument(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore; + + internal class MyClass1 + { + } + """, "MyClass1") + .Initialize(ProjectKind.SdkStyle, TargetFramework.Net60, LanguageVersion.CSharp10); + string[] args = new[] { "using", "globalize", "--project", project.File.FullName }; + //Act + await RunAsync(args); + //Assert + Console.VerifyContains(null, "No usings specified. To globalize all top-level using directives, run the command with '--force' option."); + Result.Verify(ExitCodes.Error); + } + + [Fact] + public async Task Globalize_ManySpecificUsings_ReplacesSpecifiedUsingDirectives() + { + //Arrange + var project = Workspace.CreateProject() + .AddDocument(""" + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore + { + internal class MyClass1 + { + } + } + """, "MyClass1") + .AddDocument(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore; + + internal class MyClass2 + { + } + """, "MyClass2") + .Initialize(ProjectKind.SdkStyle, TargetFramework.Net60, LanguageVersion.CSharp10); + string[] args = new[] { "using", "globalize", Usings.System_Collections_Generic, Usings.System_Linq, "--project", project.File.FullName }; + //Act + await RunAsync(args); + //Assert + Console.Verify($""" + Project: {Names.Project} + 4 occurrences of 2 Using Directives were globalized to "GlobalUsings.cs". + """); + Workspace.CreateExpectation() + .AppendFile(""" + global using System.Collections.Generic; + global using System.Linq; + + """, Names.GlobalUsings) + .AppendFile(""" + using System; + using System.IO; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore + { + internal class MyClass1 + { + } + } + """, "MyClass1.cs") + .AppendFile(""" + using System; + using System.Text; + using System.Threading.Tasks; + + namespace ProjectUnderTest.NetCore; + + internal class MyClass2 + { + } + """, "MyClass2.cs") + .AppendFile(ProjectText.Create(TargetFramework.Net60, LanguageVersion.CSharp10), Names.CSharpProject) + .Verify(); + Result.Verify(ExitCodes.Success); + } + [Fact] public async Task Globalize_ExplicitProjectFileDoesNotExist_FailsValidation() { @@ -293,7 +479,7 @@ internal class MyClass1 //Assert Console.Verify($""" Project: {Names.Project} - 1 occurrence of Using Directive "System" was globalized to "GlobalUsings.cs". + 1 occurrence of Using Directive "{Usings.System}" was globalized to "GlobalUsings.cs". """); Workspace.CreateExpectation() .AppendFile(""" diff --git a/src/tests/FlashOWare.Tool.Core.Tests/Assertions/ToolAssert.cs b/src/tests/FlashOWare.Tool.Core.Tests/Assertions/ToolAssert.cs index 3408d33..35aeeb0 100644 --- a/src/tests/FlashOWare.Tool.Core.Tests/Assertions/ToolAssert.cs +++ b/src/tests/FlashOWare.Tool.Core.Tests/Assertions/ToolAssert.cs @@ -9,24 +9,35 @@ internal static class ToolAssert public static void Equal(UsingDirective[] expected, UsingCountResult actual) { Assert.Equal("TestProject", actual.ProjectName); + Equal(expected, actual.Usings); + } + + public static Task AssertAsync(UsingGlobalizationResult actual, Project project, string localUsing, int occurrences, string targetDocument) + { + return AssertAsync(actual, project, new UsingDirective[] { new(localUsing, occurrences) }, targetDocument); + } + + public static async Task AssertAsync(UsingGlobalizationResult actual, Project project, UsingDirective[] usings, string targetDocument) + { + int occurrences = usings.Aggregate(0, static (sum, directive) => sum + directive.Occurrences); + + await RoslynAssert.EqualAsync(project, actual.Project); + Equal(usings, actual.Usings); + Assert.Equal(targetDocument, actual.TargetDocument); + Assert.Equal(occurrences, actual.Occurrences); + } - if (!expected.SequenceEqual(actual.Usings, UsingDirectiveEqualityComparer.Instance)) + private static void Equal(UsingDirective[] expected, IReadOnlyCollection actual) + { + if (!expected.SequenceEqual(actual, UsingDirectiveEqualityComparer.Instance)) { string message = $""" Expected: [{String.Join(", ", expected)}] - Actual: [{String.Join(", ", actual.Usings)}] + Actual: [{String.Join(", ", actual)}] """; throw new XunitException(message); } } - - public static async Task AssertAsync(UsingGlobalizationResult actual, Project project, string localUsing, int occurrences, string targetDocument) - { - await RoslynAssert.EqualAsync(project, actual.Project); - Assert.Equal(localUsing, actual.Using.Name); - Assert.Equal(occurrences, actual.Using.Occurrences); - Assert.Equal(targetDocument, actual.TargetDocument); - } } file sealed class UsingDirectiveEqualityComparer : IEqualityComparer diff --git a/src/tests/FlashOWare.Tool.Core.Tests/UsingDirectives/UsingGlobalizerTests.cs b/src/tests/FlashOWare.Tool.Core.Tests/UsingDirectives/UsingGlobalizerTests.cs index 4f96038..342dd7d 100644 --- a/src/tests/FlashOWare.Tool.Core.Tests/UsingDirectives/UsingGlobalizerTests.cs +++ b/src/tests/FlashOWare.Tool.Core.Tests/UsingDirectives/UsingGlobalizerTests.cs @@ -2,6 +2,7 @@ using FlashOWare.Tool.Core.Tests.Testing; using FlashOWare.Tool.Core.UsingDirectives; using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Immutable; namespace FlashOWare.Tool.Core.Tests.UsingDirectives; @@ -49,6 +50,22 @@ private static void Main(string[] args) await ToolAssert.AssertAsync(actualResult, expectedProject, "System", 0, DefaultDocumentName); } + [Fact] + public async Task GlobalizeAsync_LocalUsingDirectiveNotFound_NoReplace() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + using System.Collections.Generic; + """); + var expectedProject = await CreateProjectCheckedAsync(""" + using System.Collections.Generic; + """); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project, "System"); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, "System", 0, DefaultDocumentName); + } + [Fact] public async Task GlobalizeAsync_LocalUsingDirective_ReplacesWithGlobalUsingDirective() { @@ -554,7 +571,7 @@ public async Task GlobalizeAsync_UnsupportedLanguageVersion_Throws() } [Fact] - public async Task GlobalizeAsync_Exists_Append() + public async Task GlobalizeAsync_DocumentExists_Append() { //Arrange var project = await CreateProjectCheckedAsync(""" @@ -581,6 +598,35 @@ public async Task GlobalizeAsync_Exists_Append() await ToolAssert.AssertAsync(actualResult, expectedProject, "System.Linq", 1, DefaultDocumentName); } + [Fact] + public async Task GlobalizeAsync_AlreadyGlobalized_DoNotRepeat() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + global using System.Linq; + + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + """).WithDocumentNameAsync(0, DefaultDocumentName); + var expectedProject = await CreateProjectCheckedAsync(""" + global using System.Linq; + + """, """ + using System; + using System.Collections.Generic; + using System.Text; + using System.Threading.Tasks; + """).WithDocumentNameAsync(0, DefaultDocumentName); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project, "System.Linq"); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, "System.Linq", 1, DefaultDocumentName); + } + [Fact] public async Task GlobalizeAsync_Ambiguous_Throws() { @@ -594,4 +640,130 @@ public async Task GlobalizeAsync_Ambiguous_Throws() //Assert await Assert.ThrowsAsync(result); } + + [Fact] + public async Task GlobalizeAsync_DuplicateUsings_DeduplicatesUsingDirectives() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + using System; + using System; + """, """ + using System; + using System.Collections.Generic; + using System; + """); + var expectedProject = await CreateProjectCheckedAsync( + "", + """ + using System.Collections.Generic; + + """, """ + global using System; + + """).WithDocumentNameAsync(^1, DefaultDocumentName); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project, "System"); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, "System", 4, DefaultDocumentName); + } + + [Fact] + public async Task GlobalizeAsync_RedundantUsings_DeduplicatesUsings() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + using System; + """, """ + using System; + using System.Collections.Generic; + """); + var expectedProject = await CreateProjectCheckedAsync( + "", + "", """ + global using System; + global using System.Collections.Generic; + + """).WithDocumentNameAsync(^1, DefaultDocumentName); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project, ImmutableArray.Create("System", "System.Collections", "System", "System.Collections.Generic", "System")); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, new UsingDirective[] { new("System", 2), new("System.Collections", 0), new("System.Collections.Generic", 1) }, DefaultDocumentName); + } + + [Fact] + public async Task GlobalizeAsync_MultipleUsings_ReplacesSpecifiedUsingDirectives() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + """, """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + """); + var expectedProject = await CreateProjectCheckedAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + """, """ + global using System.IO; + global using System.Net.Http; + global using System.Threading; + + """).WithDocumentNameAsync(^1, DefaultDocumentName); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project, ImmutableArray.Create("System.IO", "System.Net.Http", "System.Threading")); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, new UsingDirective[] { new("System.IO", 2), new("System.Net.Http", 2), new("System.Threading", 2) }, DefaultDocumentName); + } + + [Fact] + public async Task GlobalizeAsync_NoUsings_ReplacesAllUsingDirectives() + { + //Arrange + var project = await CreateProjectCheckedAsync(""" + using System; + """, """ + using System; + using System.Text; + """, """ + using System; + using System.Text; + using System.Threading.Tasks; + """); + var expectedProject = await CreateProjectCheckedAsync( + "", + "", + "", """ + global using System; + global using System.Text; + global using System.Threading.Tasks; + + """).WithDocumentNameAsync(^1, DefaultDocumentName); + //Act + var actualResult = await UsingGlobalizer.GlobalizeAsync(project); + //Assert + await ToolAssert.AssertAsync(actualResult, expectedProject, new UsingDirective[] { new("System", 3), new("System.Text", 2), new("System.Threading.Tasks", 1) }, DefaultDocumentName); + } }