From 56341f53739d81ba68ccd27172fceb71eabd887d Mon Sep 17 00:00:00 2001 From: Marton Balassa <7115274+BalassaMarton@users.noreply.github.com> Date: Mon, 10 Jul 2023 11:57:56 +0200 Subject: [PATCH] Added expand-references command Closes #50 +semver:feature --- .../Commands/ChangeNamespaceTests.cs | 16 +- .../Commands/ExpandReferencesTests.cs | 128 +++++++ DotNetPlease.Tests/DotNetPlease.Tests.csproj | 2 +- DotNetPlease/Commands/ExpandReferences.cs | 336 ++++++++++++++++++ DotNetPlease/Helpers/MSBuildHelper.cs | 80 +++++ 5 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 DotNetPlease.Tests/Commands/ExpandReferencesTests.cs create mode 100644 DotNetPlease/Commands/ExpandReferences.cs diff --git a/DotNetPlease.Tests/Commands/ChangeNamespaceTests.cs b/DotNetPlease.Tests/Commands/ChangeNamespaceTests.cs index 70199d1..36faefc 100644 --- a/DotNetPlease.Tests/Commands/ChangeNamespaceTests.cs +++ b/DotNetPlease.Tests/Commands/ChangeNamespaceTests.cs @@ -1,4 +1,18 @@ -using DotNetPlease.TestUtils; +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using DotNetPlease.TestUtils; using FluentAssertions; using System.Collections.Generic; using System.Linq; diff --git a/DotNetPlease.Tests/Commands/ExpandReferencesTests.cs b/DotNetPlease.Tests/Commands/ExpandReferencesTests.cs new file mode 100644 index 0000000..784507d --- /dev/null +++ b/DotNetPlease.Tests/Commands/ExpandReferencesTests.cs @@ -0,0 +1,128 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; +using static DotNetPlease.Helpers.DotNetCliHelper; +using static DotNetPlease.Helpers.MSBuildHelper; + +namespace DotNetPlease.Commands; + +public class ExpandReferencesTests : TestFixtureBase +{ + [Theory, CombinatorialData] + public async Task It_replaces_PackageReference_with_ProjectReference(bool stage) + { + var (sourceSlnPath, sourceProjectPath) = CreateSolutionWithSingleProject("Source", "Package1"); + var (targetSlnPath, targetProjectPath) = CreateSolutionWithSingleProject("Target", "Project1"); + AddPackageReference(targetProjectPath, "Package1", "1.0.0"); + + if (stage) CreateSnapshot(); + + await RunAndAssertSuccess("expand-references", "Source/Source.sln", "Target/Target.sln", StageOption(stage)); + + if (stage) + { + VerifySnapshot(); + return; + } + + var projectReference = FindProjectReference(targetProjectPath, sourceProjectPath); + projectReference.Should().NotBeNull(); + + var packageReference = FindPackageReference(targetProjectPath, "Package1"); + packageReference.Should().BeNull(); + } + + [Theory, CombinatorialData] + public async Task It_adds_the_project_from_PackageReference_to_the_solution(bool stage) + { + var (sourceSlnPath, sourceProjectPath) = CreateSolutionWithSingleProject("Source", "Package1"); + var (targetSlnPath, targetProjectPath) = CreateSolutionWithSingleProject("Target", "Project1"); + AddPackageReference(targetProjectPath, "Package1", "1.0.0"); + + if (stage) CreateSnapshot(); + + await RunAndAssertSuccess("expand-references", "Source/Source.sln", "Target/Target.sln", StageOption(stage)); + + if (stage) + { + VerifySnapshot(); + return; + } + + var projectsInSolution = GetProjectsFromSolution(targetSlnPath); + projectsInSolution.Should().Contain(sourceProjectPath); + } + + [Theory, CombinatorialData] + public async Task It_replaces_Reference_with_ProjectReference(bool stage) + { + var (sourceSlnPath, sourceProjectPath) = CreateSolutionWithSingleProject("Source", "ClassLib1"); + var (targetSlnPath, targetProjectPath) = CreateSolutionWithSingleProject("Target", "Project1"); + AddAssemblyReference(targetProjectPath, "ClassLib1"); + + if (stage) CreateSnapshot(); + + await RunAndAssertSuccess("expand-references", "Source/Source.sln", "Target/Target.sln", StageOption(stage)); + + if (stage) + { + VerifySnapshot(); + return; + } + + var projectReference = FindProjectReference(targetProjectPath, sourceProjectPath); + projectReference.Should().NotBeNull(); + + var assemblyReference = FindAssemblyReference(targetProjectPath, "ClassLib1"); + assemblyReference.Should().BeNull(); + } + + [Theory, CombinatorialData] + public async Task It_adds_the_project_from_Reference_to_the_solution(bool stage) + { + var (sourceSlnPath, sourceProjectPath) = CreateSolutionWithSingleProject("Source", "ClassLib1"); + var (targetSlnPath, targetProjectPath) = CreateSolutionWithSingleProject("Target", "Project1"); + AddAssemblyReference(targetProjectPath, "ClassLib1"); + + if (stage) CreateSnapshot(); + + await RunAndAssertSuccess("expand-references", "Source/Source.sln", "Target/Target.sln", StageOption(stage)); + + if (stage) + { + VerifySnapshot(); + return; + } + + var projectsInSolution = GetProjectsFromSolution(targetSlnPath); + projectsInSolution.Should().Contain(sourceProjectPath); + } + + public ExpandReferencesTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + private (string solutionPath, string projectPath) CreateSolutionWithSingleProject(string solutionName, string projectName) + { + var solutionPath = GetFullPath($"{solutionName}/{solutionName}.sln"); + var projectPath = GetFullPath($"{solutionName}/{projectName}/{projectName}.csproj"); + CreateProject(projectPath); + CreateSolution(solutionPath); + AddProjectToSolution(projectPath, solutionPath); + + return (solutionPath, projectPath); + } +} diff --git a/DotNetPlease.Tests/DotNetPlease.Tests.csproj b/DotNetPlease.Tests/DotNetPlease.Tests.csproj index 7b8b3dc..ddadb20 100644 --- a/DotNetPlease.Tests/DotNetPlease.Tests.csproj +++ b/DotNetPlease.Tests/DotNetPlease.Tests.csproj @@ -23,4 +23,4 @@ - \ No newline at end of file + diff --git a/DotNetPlease/Commands/ExpandReferences.cs b/DotNetPlease/Commands/ExpandReferences.cs new file mode 100644 index 0000000..8693551 --- /dev/null +++ b/DotNetPlease/Commands/ExpandReferences.cs @@ -0,0 +1,336 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System; +using DotNetPlease.Annotations; +using DotNetPlease.Constants; +using DotNetPlease.Internal; +using DotNetPlease.Services.Reporting.Abstractions; +using JetBrains.Annotations; +using MediatR; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading; +using System.Linq; +using System.Threading.Tasks; +using DotNetPlease.Helpers; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; +using static DotNetPlease.Helpers.FileSystemHelper; +using static DotNetPlease.Helpers.MSBuildHelper; + +namespace DotNetPlease.Commands; + +public static class ExpandReferences +{ + [Command( + "expand-references", + "Includes projects from the specified solution or globbing pattern to the current solution, replacing any Reference and PackageReference items")] + public class Command : IRequest + { + [Argument(0, "The projects or solutions to include")] + public string ProjectsOrSolutions { get; set; } = null!; + + [Argument(1, CommandArguments.SolutionFileName.Description)] + public string? SolutionFileName { get; set; } + } + + [UsedImplicitly] + public class CommandHandler : CommandHandlerBase + { + protected override Task Handle(Command command, CancellationToken cancellationToken) + { + var context = CreateContext(command); + + DiscoverProjectsToInclude(context); + FindReferencesToReplace(context); + ReplaceReferencesWithProjectReference(context); + AddProjectsToCurrentSolution(context); + + if (context.FilesUpdated.Count == 0) + { + Reporter.Success("Nothing to update"); + } + + return Task.CompletedTask; + } + + private void DiscoverProjectsToInclude(Context context) + { + Reporter.Info("Discovering projects to include"); + + context.ProjectsInSource.AddRange( + GetProjectInfosFromGlob( + context.Command.ProjectsOrSolutions, + Workspace.WorkingDirectory, + allowSolutions: true)); + + CreateLookupsForIncludedProjects(context); + } + + private void AddProjectsToCurrentSolution(Context context) + { + var slnRelativePath = Path.GetRelativePath(Workspace.WorkingDirectory, context.SolutionFileName); + + var projectsToInclude = new HashSet(PathComparer); + + foreach (var packageId in context.PackagesToReplace) + { + IncludeProjectWithDependencies(context.PackageNameToProjectFileName[packageId]); + } + + foreach (var assemblyName in context.AssembliesToReplace) + { + IncludeProjectWithDependencies(context.AssemblyNameToProjectFileName[assemblyName]); + } + + using (Reporter.BeginScope($"Solution {slnRelativePath}")) + { + foreach (var source in context.ProjectsInSource.ToLookup(x => x.SolutionFileName ?? "")) + { + var solutionFolder = source.Key == "" + ? "" + : Path.GetFileNameWithoutExtension(Path.GetFileName(source.Key)); + + foreach (var projectInfo in source.Where(p => projectsToInclude.Contains(p.ProjectFileName))) + { + var projectRelativePath = Path.GetRelativePath( + Path.GetDirectoryName(context.SolutionFileName), + projectInfo.ProjectFileName); + + Reporter.Success($"Add project {projectRelativePath}"); + + if (!Workspace.IsStaging) + { + if (solutionFolder == "") + { + DotNetCliHelper.AddProjectToSolution( + projectInfo.ProjectFileName, + context.SolutionFileName); + } + else + { + DotNetCliHelper.AddProjectToSolution( + projectInfo.ProjectFileName, + context.SolutionFileName, + solutionFolder); + } + + context.FilesUpdated.Add(context.SolutionFileName); + } + } + } + } + + void IncludeProjectWithDependencies(string projectFileName) + { + if (projectsToInclude.Contains(projectFileName)) + return; + + projectsToInclude.Add(projectFileName); + + var project = LoadProjectFromFile(projectFileName); + + foreach (var projectRef in project.AllEvaluatedItems.Where(i => i.ItemType == "ProjectReference")) + { + IncludeProjectWithDependencies( + Path.GetFullPath(projectRef.EvaluatedInclude, Path.GetDirectoryName(projectFileName))); + } + } + } + + private void CreateLookupsForIncludedProjects(Context context) + { + foreach (var projectInfo in context.ProjectsInSource) + { + var project = LoadProjectFromFile( + projectInfo.ProjectFileName, + new ProjectOptions + { + LoadSettings = + ProjectLoadSettings.DoNotEvaluateElementsWithFalseCondition + | ProjectLoadSettings.IgnoreInvalidImports + | ProjectLoadSettings.IgnoreMissingImports, + ProjectCollection = new ProjectCollection(), + }); + + var packageName = + project.GetProperty("PackageId")?.EvaluatedValue + ?? project.GetProperty("AssemblyName")?.EvaluatedValue + ?? Path.GetFileNameWithoutExtension(Path.GetFileName(projectInfo.ProjectFileName)); + + context.PackageNameToProjectFileName[packageName] = projectInfo.ProjectFileName; + context.ProjectFileNameToPackageName[projectInfo.ProjectFileName] = packageName; + + var assemblyName = + project.GetProperty("AssemblyName")?.EvaluatedValue + ?? Path.GetFileNameWithoutExtension(Path.GetFileName(projectInfo.ProjectFileName)); + + context.AssemblyNameToProjectFileName[assemblyName] = projectInfo.ProjectFileName; + context.ProjectFileNameToAssemblyName[projectInfo.ProjectFileName] = assemblyName; + } + } + + private void FindReferencesToReplace(Context context) + { + var projects = LoadProjects(GetProjectsFromSolution(context.SolutionFileName)); + + foreach (var project in projects) + { + FindReferencesToReplace(context, project); + } + } + + private void FindReferencesToReplace(Context context, Project project) + { + var packageRefs = project.Items.Where(x => x.ItemType == "PackageReference").ToList(); + + foreach (var packageRef in packageRefs) + { + if (context.PackageNameToProjectFileName.ContainsKey(packageRef.EvaluatedInclude)) + { + context.PackagesToReplace.Add(packageRef.EvaluatedInclude); + } + } + + var assemblyRefs = project.Items.Where(x => x.ItemType == "Reference").ToList(); + + foreach (var assemblyRef in assemblyRefs) + { + if (context.AssemblyNameToProjectFileName.ContainsKey(assemblyRef.EvaluatedInclude)) + { + context.AssembliesToReplace.Add(assemblyRef.EvaluatedInclude); + } + } + } + + private void ReplaceReferencesWithProjectReference(Context context) + { + var projects = LoadProjects(GetProjectsFromSolution(context.SolutionFileName)); + + foreach (var project in projects) + { + ReplaceReferencesInProject(context, project); + + if (project.Xml.HasUnsavedChanges) + { + if (!Workspace.IsStaging) + { + project.Xml.Save(); + } + + context.FilesUpdated.Add(project.FullPath); + } + } + } + + private void ReplaceReferencesInProject(Context context, Project project) + { + var projectRelativePath = Path.GetRelativePath(Workspace.WorkingDirectory, project.FullPath); + + using (Reporter.BeginScope($"Project {projectRelativePath}")) + { + var projectPaths = new HashSet(PathComparer); + + var packageRefs = project.Xml.Items.Where(x => x.ElementName == "PackageReference").ToList(); + + foreach (var packageRef in packageRefs) + { + if (context.PackageNameToProjectFileName.TryGetValue( + packageRef.Include, + out var refProjectFileName)) + { + packageRef.Parent.RemoveChild(packageRef); + var projectPath = Path.GetRelativePath(project.DirectoryPath, refProjectFileName); + projectPaths.Add(projectPath); + + Reporter.Success( + $"Replace PackageReference \"{packageRef.Include}\" => ProjectReference \"{projectPath}\""); + } + } + + var assemblyRefs = project.Xml.Items.Where(x => x.ElementName == "Reference").ToList(); + + foreach (var assemblyRef in assemblyRefs) + { + if (context.AssemblyNameToProjectFileName.TryGetValue( + assemblyRef.Include, + out var refProjectFileName)) + { + assemblyRef.Parent.RemoveChild(assemblyRef); + var projectPath = Path.GetRelativePath(project.DirectoryPath, refProjectFileName); + projectPaths.Add(projectPath); + + Reporter.Success( + $"Replace Reference \"{assemblyRef.Include}\" => ProjectReference \"{projectPath}\""); + } + } + + if (projectPaths.Count == 0) + return; + + var itemGroup = project.Xml.AddItemGroup(); + + foreach (var projectPath in projectPaths) + { + itemGroup.AddItem("ProjectReference", projectPath); + } + } + } + + private Context CreateContext(Command command) + { + return new Context(command) + { + SolutionFileName = Workspace.FindSolutionFileName(command.SolutionFileName) + ?? throw new ValidationException("Could not find the target solution") + }; + } + + private class Context + { + public Command Command { get; } + + public Context(Command command) + { + Command = command; + } + + public string SolutionFileName { get; set; } + + public List ProjectsInSource { get; } = new List(); + + public Dictionary PackageNameToProjectFileName { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public Dictionary AssemblyNameToProjectFileName { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public Dictionary ProjectFileNameToPackageName { get; } = + new(PathComparer); + + public Dictionary ProjectFileNameToAssemblyName { get; } = + new(PathComparer); + + public HashSet PackagesToReplace { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + public HashSet AssembliesToReplace { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + public HashSet FilesUpdated { get; } = new HashSet(PathComparer); + } + + public CommandHandler(CommandHandlerDependencies dependencies) : base(dependencies) { } + } +} diff --git a/DotNetPlease/Helpers/MSBuildHelper.cs b/DotNetPlease/Helpers/MSBuildHelper.cs index f02ca5e..cba2d44 100644 --- a/DotNetPlease/Helpers/MSBuildHelper.cs +++ b/DotNetPlease/Helpers/MSBuildHelper.cs @@ -202,6 +202,58 @@ public static List GetProjectsFromGlob( return RemoveProjectsInExcludedProjectDirectories(projects); } + public static List GetProjectInfosFromGlob( + string pattern, + string? workingDirectory = null, + bool allowSolutions = true) + { + if (pattern == null) throw new ArgumentNullException(nameof(pattern)); + workingDirectory ??= Directory.GetCurrentDirectory(); + + var files = new List(); + + var matcher = new Matcher(); + foreach (var segment in pattern!.Split('|').Select(x => x.Trim())) + { + if (File.Exists(segment)) + { + files.Add(segment); + } + else + { + matcher.AddInclude(segment); + } + } + + files.AddRange(matcher.GetResultsInFullPath(workingDirectory)); + + var projects = new List(); + + foreach (var fileName in files) + { + var ext = Path.GetExtension(fileName); + + if (string.Equals(".sln", ext, StringComparison.OrdinalIgnoreCase)) + { + if (allowSolutions) + { + projects.AddRange(GetProjectsFromSolution(fileName).Select(p => new ProjectInfo(p, fileName))); + } + } + else if (KnownProjectFileExtensions.Contains(ext)) + { + projects.Add(new ProjectInfo(fileName)); + } + } + + var includedProjectFileNames = + RemoveProjectsInExcludedProjectDirectories(projects.Select(p => p.ProjectFileName).ToList()) + .ToHashSet(); + + return projects.Where(p => includedProjectFileNames.Contains(p.ProjectFileName)).ToList(); + + } + public static List GetProjectsFromDirectory( string path, bool recursive) @@ -373,6 +425,20 @@ public static void AddPackageReference(string projectFileName, string packageId, project.Save(); } + public static void AddAssemblyReference(Project project, string assemblyName) + { + project.AddItemFast( + "Reference", + assemblyName); + } + + public static void AddAssemblyReference(string projectFileName, string assemblyName) + { + var project = LoadProjectFromFile(projectFileName); + AddAssemblyReference(project, assemblyName); + project.Save(); + } + public static ProjectItem? FindProjectReference(Project project, string referencedProjectFileName) { var reference = NormalizePath( @@ -387,6 +453,18 @@ public static void AddPackageReference(string projectFileName, string packageId, return FindProjectReference(project, referencedProjectFileName); } + public static ProjectItem? FindAssemblyReference(Project project, string assemblyName) + { + return project.AllEvaluatedItems.FirstOrDefault( + i => i.ItemType == "Reference" && string.Equals(i.UnevaluatedInclude, assemblyName, StringComparison.OrdinalIgnoreCase)); + } + + public static ProjectItem? FindAssemblyReference(string projectFileName, string assemblyName) + { + var project = LoadProjectFromFile(projectFileName); + return FindAssemblyReference(project, assemblyName); + } + public static ProjectItem? FindPackageReference(Project project, string packageId) { return project.AllEvaluatedItems.FirstOrDefault( @@ -482,4 +560,6 @@ public static string GetHiddenVsDirectory(string solutionFileName) return $".vs/{hiddenDirectoryName}"; } } + + public readonly record struct ProjectInfo(string ProjectFileName, string? SolutionFileName = null); } \ No newline at end of file