diff --git a/src/Build.UnitTests/Graph/ProjectGraph_Tests.cs b/src/Build.UnitTests/Graph/ProjectGraph_Tests.cs index 932c3c8e439..f929d2b9d34 100644 --- a/src/Build.UnitTests/Graph/ProjectGraph_Tests.cs +++ b/src/Build.UnitTests/Graph/ProjectGraph_Tests.cs @@ -760,7 +760,6 @@ public void ConstructGraphWithSolution() EndGlobal """; TransientTestFile slnFile = env.CreateFile(@"Solution.sln", SolutionFileContents); - SolutionFile solutionFile = SolutionFile.Parse(slnFile.Path); ProjectRootElement project1Xml = ProjectRootElement.Create(); @@ -829,6 +828,74 @@ public void ConstructGraphWithSolution() } } + [Fact] + public void GetTargetListsWithSemicolonInTarget() + { + using (var env = TestEnvironment.Create()) + { + TransientTestFile entryProject = CreateProjectFile(env, 1); + + var projectGraph = new ProjectGraph(entryProject.Path); + projectGraph.ProjectNodes.Count.ShouldBe(1); + projectGraph.ProjectNodes.First().ProjectInstance.FullPath.ShouldBe(entryProject.Path); + + // Example: msbuild /graph /t:"Clean;Build". For projects, this does not expand + IReadOnlyDictionary> targetLists = projectGraph.GetTargetLists(new[] { "Clean;Build" }); + targetLists.Count.ShouldBe(projectGraph.ProjectNodes.Count); + targetLists[GetFirstNodeWithProjectNumber(projectGraph, 1)].ShouldBe(new[] { "Clean;Build" }); + } + } + + [Fact] + public void GetTargetListsWithSemicolonInTargetSolution() + { + using (var env = TestEnvironment.Create()) + { + TransientTestFile project = CreateProjectFile(env, 1); + + string solutionFileContents = $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + # Visual Studio Version 17 + VisualStudioVersion = 17.0.31903.59 + MinimumVisualStudioVersion = 17.0.31903.59 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Project1", "{{project.Path}}", "{8761499A-7280-43C4-A32F-7F41C47CA6DF}" + EndProject + Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Debug|x64.ActiveCfg = Debug|x64 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Debug|x64.Build.0 = Debug|x64 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Debug|x86.ActiveCfg = Debug|x86 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Debug|x86.Build.0 = Debug|x86 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Release|x64.ActiveCfg = Release|x64 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Release|x64.Build.0 = Release|x64 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Release|x86.ActiveCfg = Release|x86 + {8761499A-7280-43C4-A32F-7F41C47CA6DF}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + EndGlobal + """; + TransientTestFile slnFile = env.CreateFile(@"Solution.sln", solutionFileContents); + SolutionFile solutionFile = SolutionFile.Parse(slnFile.Path); + + var projectGraph = new ProjectGraph(slnFile.Path); + projectGraph.ProjectNodes.Count.ShouldBe(1); + projectGraph.ProjectNodes.First().ProjectInstance.FullPath.ShouldBe(project.Path); + + // Example: msbuild /graph /t:"Clean;Build". For solutions, this does expand! + IReadOnlyDictionary> targetLists = projectGraph.GetTargetLists(new[] { "Clean;Build" }); + targetLists.Count.ShouldBe(projectGraph.ProjectNodes.Count); + targetLists[GetFirstNodeWithProjectNumber(projectGraph, 1)].ShouldBe(new[] { "Clean", "Build" }); + } + } + [Fact] public void GetTargetListsAggregatesFromMultipleEdges() { diff --git a/src/Build/Graph/GraphBuilder.cs b/src/Build/Graph/GraphBuilder.cs index 58f9af58bdf..929855b1200 100644 --- a/src/Build/Graph/GraphBuilder.cs +++ b/src/Build/Graph/GraphBuilder.cs @@ -21,7 +21,7 @@ namespace Microsoft.Build.Graph { - internal class GraphBuilder + internal sealed class GraphBuilder { internal const string SolutionItemReference = "_SolutionReference"; @@ -38,6 +38,8 @@ internal class GraphBuilder public GraphEdges Edges { get; private set; } + public bool IsSolution { get; private set; } + private readonly List _entryPointConfigurationMetadata; private readonly ParallelWorkSet _graphWorkSet; @@ -257,8 +259,7 @@ private static void AddEdgesFromSolution(IReadOnlyDictionary e.ProjectFile)))); } - ErrorUtilities.VerifyThrowArgument(entryPoints.Count == 1, "StaticGraphAcceptsSingleSolutionEntryPoint"); - + IsSolution = true; ProjectGraphEntryPoint solutionEntryPoint = entryPoints.Single(); ImmutableDictionary.Builder solutionGlobalPropertiesBuilder = ImmutableDictionary.CreateBuilder( keyComparer: StringComparer.OrdinalIgnoreCase, diff --git a/src/Build/Graph/ProjectGraph.cs b/src/Build/Graph/ProjectGraph.cs index 39993e3a4fc..5aa325ceae9 100644 --- a/src/Build/Graph/ProjectGraph.cs +++ b/src/Build/Graph/ProjectGraph.cs @@ -56,9 +56,11 @@ public delegate ProjectInstance ProjectInstanceFactoryFunc( private readonly Lazy> _projectNodesTopologicallySorted; - private GraphBuilder.GraphEdges Edges { get; } + private readonly bool _isSolution; - internal GraphBuilder.GraphEdges TestOnly_Edges => Edges; + private readonly GraphBuilder.GraphEdges _edges; + + internal GraphBuilder.GraphEdges TestOnly_Edges => _edges; public GraphConstructionMetrics ConstructionMetrics { get; private set; } @@ -432,7 +434,8 @@ public ProjectGraph( EntryPointNodes = graphBuilder.EntryPointNodes; GraphRoots = graphBuilder.RootNodes; ProjectNodes = graphBuilder.ProjectNodes; - Edges = graphBuilder.Edges; + _edges = graphBuilder.Edges; + _isSolution = graphBuilder.IsSolution; _projectNodesTopologicallySorted = new Lazy>(() => TopologicalSort(GraphRoots, ProjectNodes)); @@ -472,7 +475,7 @@ GraphConstructionMetrics EndMeasurement() return new GraphConstructionMetrics( measurementInfo.Timer.Elapsed, ProjectNodes.Count, - Edges.Count); + _edges.Count); } } @@ -598,6 +601,24 @@ public IReadOnlyDictionary> GetTargetLis { ThrowOnEmptyTargetNames(entryProjectTargets); + // Solutions have quirky behavior when provided a target with ';' in it, eg "Clean;Build". This can happen if via the command-line the user provides something + // like /t:"Clean;Build". When building a project, the target named "Clean;Build" is executed (which usually doesn't exist, but could). However, for solutions + // the generated metaproject ends up calling the MSBuild task with the provided targets, which ends up splitting the value as if it were [ "Clean", "Build" ]. + // Mimic this flattening behavior for consistency. + if (_isSolution && entryProjectTargets != null && entryProjectTargets.Count != 0) + { + List newEntryTargets = new(entryProjectTargets.Count); + foreach (string entryTarget in entryProjectTargets) + { + foreach (string s in ExpressionShredder.SplitSemiColonSeparatedList(entryTarget)) + { + newEntryTargets.Add(s); + } + } + + entryProjectTargets = newEntryTargets; + } + // Seed the dictionary with empty lists for every node. In this particular case though an empty list means "build nothing" rather than "default targets". var targetLists = ProjectNodes.ToDictionary(node => node, node => ImmutableList.Empty); @@ -606,10 +627,10 @@ public IReadOnlyDictionary> GetTargetLis foreach (ProjectGraphNode entryPointNode in EntryPointNodes) { - var entryTargets = entryProjectTargets == null || entryProjectTargets.Count == 0 + ImmutableList nodeEntryTargets = entryProjectTargets == null || entryProjectTargets.Count == 0 ? ImmutableList.CreateRange(entryPointNode.ProjectInstance.DefaultTargets) : ImmutableList.CreateRange(entryProjectTargets); - var entryEdge = new ProjectGraphBuildRequest(entryPointNode, entryTargets); + var entryEdge = new ProjectGraphBuildRequest(entryPointNode, nodeEntryTargets); encounteredEdges.Add(entryEdge); edgesToVisit.Enqueue(entryEdge); } @@ -645,7 +666,7 @@ public IReadOnlyDictionary> GetTargetLis var expandedTargets = ExpandDefaultTargets( applicableTargets, referenceNode.ProjectInstance.DefaultTargets, - Edges[(node, referenceNode)]); + _edges[(node, referenceNode)]); var projectReferenceEdge = new ProjectGraphBuildRequest( referenceNode,