Skip to content

Commit 3397294

Browse files
authored
Initial implemenation of a fluent API for generating Visual Studio solutions (#341)
1 parent b59fdb4 commit 3397294

26 files changed

+1298
-95
lines changed

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageVersion Include="Microsoft.IO.Redist" Version="6.1.3" />
1515
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
1616
<PackageVersion Include="Microsoft.VisualStudio.Setup.Configuration.Interop" Version="3.14.2075" />
17+
<PackageVersion Include="Microsoft.VisualStudio.SolutionPersistence" Version="1.0.52" />
1718
<PackageVersion Include="Shouldly" Version="4.3.0" />
1819
<PackageVersion Include="System.IO.Compression" Version="4.3.0" />
1920
<PackageVersion Include="xunit" Version="2.9.3" />
@@ -27,4 +28,4 @@
2728
<Compile Include="..\Shared\GlobalSuppressions.cs" />
2829
<AdditionalFiles Include="..\Shared\stylecop.json" />
2930
</ItemGroup>
30-
</Project>
31+
</Project>

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ And the resulting project would look like this:
225225
</Project>
226226
```
227227

228+
# Visual Studio Solutions
229+
You can create Visual Studio solutions with the `SolutionCreator` class.
230+
This class is a wrapper around the [VS-SolutionPersistence] library which supports both `.sln` and `.slnx` solution file formats.
231+
232+
The following example creates a solution with two projects:
233+
```C#
234+
ProjectCreator project1 = ProjectCreator.Templates.SdkCsproj(path: Path.Combine(Environment.CurrentDirectory, "project1", "project1.csproj"));
235+
236+
SolutionCreator.Create(Path.Combine(Environment.CurrentDirectory, "solution1.sln"))
237+
.Configuration("Debug")
238+
.Configuration("Release")
239+
.Platform("Any CPU")
240+
.Project(project1)
241+
.Save();
242+
```
243+
228244
# Package Repositories and Feeds
229245
NuGet and MSBuild are very tightly coupled and a lot of times you need packages available when building projects. This API offers two solutions:
230246

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Jeff Kluge. All rights reserved.
2+
//
3+
// Licensed under the MIT license.
4+
5+
using Microsoft.VisualStudio.SolutionPersistence.Model;
6+
using Shouldly;
7+
using System.IO;
8+
using Xunit;
9+
10+
namespace Microsoft.Build.Utilities.ProjectCreation.UnitTests
11+
{
12+
public class SolutionTests : TestBase
13+
{
14+
[Fact]
15+
public void BasicTest()
16+
{
17+
string solutionFileFullPath = Path.Combine(TestRootPath, "solution1.sln");
18+
19+
string project1Name = "project1";
20+
21+
string project1FullPath = Path.Combine(TestRootPath, project1Name, "project1.csproj");
22+
23+
ProjectCreator project1 = ProjectCreator.Templates.SdkCsproj(project1FullPath);
24+
25+
SolutionCreator solution = SolutionCreator.Create(solutionFileFullPath)
26+
.TryProject(project1, projectInSolution: out SolutionProjectModel projectInSolution)
27+
.Save();
28+
29+
File.ReadAllText(solutionFileFullPath).ShouldBe(
30+
@$"Microsoft Visual Studio Solution File, Format Version 12.00
31+
Project(""{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}"") = ""{project1Name}"", ""{project1FullPath.Replace('/', '\\')}"", ""{{{projectInSolution.Id.ToString().ToUpperInvariant()}}}""
32+
EndProject
33+
Global
34+
GlobalSection(SolutionProperties) = preSolution
35+
HideSolutionNode = FALSE
36+
EndGlobalSection
37+
EndGlobal
38+
",
39+
StringCompareShould.IgnoreLineEndings);
40+
}
41+
42+
[Fact]
43+
public void CanBuild()
44+
{
45+
ProjectCreator project1 = ProjectCreator.Templates.SdkCsproj(path: Path.Combine(TestRootPath, "project1", "project1.csproj"));
46+
47+
SolutionCreator.Create(Path.Combine(TestRootPath, "solution1.sln"))
48+
.Configuration("Debug")
49+
.Configuration("Release")
50+
.Platform("Any CPU")
51+
.Project(project1)
52+
.TryBuild(out _);
53+
}
54+
}
55+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) Jeff Kluge. All rights reserved.
2+
//
3+
// Licensed under the MIT license.
4+
5+
using Microsoft.Build.Evaluation;
6+
using Microsoft.Build.Execution;
7+
using Microsoft.Build.Framework;
8+
using System;
9+
using System.Collections.Generic;
10+
11+
namespace Microsoft.Build.Utilities.ProjectCreation
12+
{
13+
internal static class BuildHost
14+
{
15+
private static readonly IDictionary<string, string> EmptyGlobalProperties = new Dictionary<string, string>(capacity: 0);
16+
17+
#if !NET8_0
18+
private static readonly IDictionary<string, string?> EmptyGlobalPropertiesWithNull = new Dictionary<string, string?>(capacity: 0);
19+
#endif
20+
21+
public static bool Restore(
22+
string projectFullPath,
23+
ProjectCollection projectCollection,
24+
IDictionary<string, string>? globalProperties,
25+
BuildOutput buildOutput,
26+
out IDictionary<string, TargetResult>? targetOutputs)
27+
{
28+
Dictionary<string, string> restoreGlobalProperties = new(globalProperties ?? projectCollection.GlobalProperties); // IMPORTANT: Make a copy of the global properties here so as not to modify the ones passed in
29+
30+
restoreGlobalProperties["ExcludeRestorePackageImports"] = "true";
31+
restoreGlobalProperties["MSBuildRestoreSessionId"] = Guid.NewGuid().ToString("D");
32+
33+
BuildRequestDataFlags buildRequestDataFlags = BuildRequestDataFlags.ClearCachesAfterBuild | BuildRequestDataFlags.SkipNonexistentTargets | BuildRequestDataFlags.IgnoreMissingEmptyAndInvalidImports;
34+
35+
return BuildProjectFromFullPath(projectFullPath, ["Restore"], restoreGlobalProperties, [.. projectCollection.Loggers, buildOutput], buildRequestDataFlags, out targetOutputs);
36+
}
37+
38+
public static bool TryBuild(
39+
string projectFullPath,
40+
ProjectCollection projectCollection,
41+
BuildOutput buildOutput,
42+
out IDictionary<string, TargetResult>? targetOutputs,
43+
bool restore = false,
44+
string? target = null,
45+
IDictionary<string, string>? globalProperties = null)
46+
{
47+
return Build(projectFullPath, restore, target is null ? Array.Empty<string>() : [target], projectCollection, globalProperties, buildOutput, out targetOutputs);
48+
}
49+
50+
public static bool TryBuild(
51+
string projectFullPath,
52+
ProjectCollection projectCollection,
53+
BuildOutput buildOutput,
54+
out IDictionary<string, TargetResult>? targetOutputs,
55+
bool restore = false,
56+
string[]? targets = null,
57+
IDictionary<string, string>? globalProperties = null)
58+
{
59+
return Build(projectFullPath, restore, targets ?? Array.Empty<string>(), projectCollection, globalProperties, buildOutput, out targetOutputs);
60+
}
61+
62+
public static bool TryBuild(
63+
ProjectInstance projectInstance,
64+
ProjectCollection projectCollection,
65+
BuildOutput buildOutput,
66+
out IDictionary<string, TargetResult>? targetOutputs,
67+
string? target = null,
68+
IDictionary<string, string>? globalProperties = null)
69+
{
70+
return Build(projectInstance, target is null ? Array.Empty<string>() : [target], projectCollection, globalProperties, buildOutput, out targetOutputs);
71+
}
72+
73+
public static bool TryBuild(
74+
ProjectInstance projectInstance,
75+
ProjectCollection projectCollection,
76+
BuildOutput buildOutput,
77+
out IDictionary<string, TargetResult>? targetOutputs,
78+
string[]? targets = null,
79+
IDictionary<string, string>? globalProperties = null)
80+
{
81+
return Build(projectInstance, targets ?? Array.Empty<string>(), projectCollection, globalProperties, buildOutput, out targetOutputs);
82+
}
83+
84+
private static bool Build(
85+
string projectFullPath,
86+
bool restore,
87+
string[] targets,
88+
ProjectCollection projectCollection,
89+
IDictionary<string, string>? globalProperties,
90+
BuildOutput buildOutput,
91+
out IDictionary<string, TargetResult>? targetOutputs)
92+
{
93+
targetOutputs = null;
94+
95+
if (restore)
96+
{
97+
if (!Restore(projectFullPath, projectCollection, globalProperties, buildOutput, out _))
98+
{
99+
return false;
100+
}
101+
}
102+
103+
return BuildProjectFromFullPath(projectFullPath, targets, globalProperties, [.. projectCollection.Loggers, buildOutput], BuildRequestDataFlags.None, out targetOutputs);
104+
}
105+
106+
private static bool Build(
107+
ProjectInstance projectInstance,
108+
string[] targets,
109+
ProjectCollection projectCollection,
110+
IDictionary<string, string>? globalProperties,
111+
BuildOutput buildOutput,
112+
out IDictionary<string, TargetResult>? targetOutputs)
113+
{
114+
targetOutputs = null;
115+
116+
return BuildProjectFromProjectInstance(projectInstance, targets, globalProperties, [.. projectCollection.Loggers, buildOutput], BuildRequestDataFlags.None, out targetOutputs);
117+
}
118+
119+
private static bool BuildProjectFromProjectInstance(
120+
ProjectInstance projectInstance,
121+
string[] targets,
122+
IDictionary<string, string>? globalProperties,
123+
IEnumerable<ILogger> loggers,
124+
BuildRequestDataFlags buildRequestDataFlags,
125+
out IDictionary<string, TargetResult>? targetOutputs)
126+
{
127+
targetOutputs = null;
128+
129+
BuildResult buildResult = BuildManagerHost.Build(
130+
projectInstance,
131+
targets,
132+
globalProperties ?? EmptyGlobalProperties,
133+
loggers,
134+
buildRequestDataFlags);
135+
136+
if (buildResult.Exception != null)
137+
{
138+
throw buildResult.Exception;
139+
}
140+
141+
targetOutputs = buildResult.ResultsByTarget;
142+
143+
return buildResult.OverallResult == BuildResultCode.Success;
144+
}
145+
146+
private static bool BuildProjectFromFullPath(
147+
string projectFullPath,
148+
string[] targets,
149+
IDictionary<string, string>? globalProperties,
150+
IEnumerable<ILogger> loggers,
151+
BuildRequestDataFlags buildRequestDataFlags,
152+
out IDictionary<string, TargetResult>? targetOutputs)
153+
{
154+
targetOutputs = null;
155+
156+
BuildResult buildResult = BuildManagerHost.Build(
157+
projectFullPath,
158+
targets,
159+
GetGlobalProperties(globalProperties),
160+
loggers,
161+
buildRequestDataFlags);
162+
163+
if (buildResult.Exception != null)
164+
{
165+
throw buildResult.Exception;
166+
}
167+
168+
if (targetOutputs != null)
169+
{
170+
foreach (KeyValuePair<string, TargetResult> targetResult in buildResult.ResultsByTarget)
171+
{
172+
targetOutputs[targetResult.Key] = targetResult.Value;
173+
}
174+
}
175+
else
176+
{
177+
targetOutputs = buildResult.ResultsByTarget;
178+
}
179+
180+
return buildResult.OverallResult == BuildResultCode.Success;
181+
182+
#if NET8_0
183+
IDictionary<string, string>
184+
#else
185+
IDictionary<string, string?>
186+
#endif
187+
GetGlobalProperties(IDictionary<string, string>? globalProperties)
188+
{
189+
#if NET8_0
190+
return globalProperties ?? EmptyGlobalProperties;
191+
#else
192+
if (globalProperties is null)
193+
{
194+
return EmptyGlobalPropertiesWithNull;
195+
}
196+
197+
Dictionary<string, string?> finalGlobalProperties = new(globalProperties.Count);
198+
199+
foreach (var kvp in globalProperties)
200+
{
201+
finalGlobalProperties[kvp.Key] = kvp.Value;
202+
}
203+
204+
return finalGlobalProperties;
205+
#endif
206+
}
207+
}
208+
}
209+
}

src/Microsoft.Build.Utilities.ProjectCreation/ExtensionMethods.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,6 @@ public static IEnumerable<T> AsEnumerable<T>(this T? item)
7373
return result;
7474
}
7575

76-
/// <summary>
77-
/// Gets the current object as an array of objects.
78-
/// </summary>
79-
/// <typeparam name="T">The type of the object.</typeparam>
80-
/// <param name="item">The item to make into an array.</param>
81-
/// <returns>An array of T objects.</returns>
82-
[DebuggerStepThrough]
83-
public static T[] ToArrayWithSingleElement<T>(this T item)
84-
where T : class
85-
{
86-
return new[]
87-
{
88-
item,
89-
};
90-
}
91-
9276
/// <summary>
9377
/// Creates an entry in the current <see cref="ZipArchive" /> based on the specified <see cref="Stream" />.
9478
/// </summary>

src/Microsoft.Build.Utilities.ProjectCreation/Microsoft.Build.Utilities.ProjectCreation.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="All" Condition="'$(OfficialBuild)' != 'true'" />
3333
<PackageReference Include="Microsoft.IO.Redist" Condition="'$(TargetFramework)' == 'net472'" />
3434
<PackageReference Include="Microsoft.VisualStudio.Setup.Configuration.Interop" Condition="'$(TargetFramework)' == 'net472'" ExcludeAssets="Runtime" PrivateAssets="All" />
35+
<PackageReference Include="Microsoft.VisualStudio.SolutionPersistence" />
3536
<PackageReference Include="System.IO.Compression" Condition="'$(TargetFramework)' == 'net472'" />
3637
<PackageReference Include="System.ValueTuple" VersionOverride="4.5.0" Condition="'$(TargetFramework)' == 'net472'" ExcludeAssets="Compile" />
3738
</ItemGroup>
@@ -55,6 +56,6 @@
5556

5657
<ItemGroup>
5758
<None Include="PublicAPI\**" />
58-
<AdditionalFiles Include="PublicAPI\$(TargetFramework)\PublicAPI.*.txt" />
59+
<AdditionalFiles Include="PublicAPI\$(TargetFramework)\PublicAPI.*.txt" />
5960
</ItemGroup>
6061
</Project>

0 commit comments

Comments
 (0)