MSBuild + .NET Core CLI Tools: Getting information about the project
Replacing project.json APIs with an MSBuild target
The .NET Core CLI 1.0.0 has a feature called “project tools extensions”, often called “CLI tools”.
These are project-specific, command-line tools that extend the dotnet
command with new verbs.
For example, users can install Microsoft.DotNet.Watcher.Tools
to add the dotnet watch
command.
This post will cover an advanced topic of how to implement these tools to get information about a
user’s project.
For a primer on how to create a tool, see .NET Core command-line interface tools on docs.microsoft.com.
For a primer on MSBuild, see MSBuild Concepts on docs.microsoft.com.
TL;DR See this example: https://gist.github.com/natemcmaster/ced86a82f5faeca2d4f81fad2fdc7c04
Learn by example
For the sake of this tutorial, our goal is to create a tool called dotnet-names
. When installed,
a user can invoked dotnet names
and the tool will list the assembly name, root namespace, and
names of target frameworks in a given project.
Goals:
- Tool must not require the user to add additional dependencies.
- The tool must support MSBuild for .NET Core projects.
Step 0. The mental migration from project.json
Tool authors with existing tools that read the project.json
will already be familiar with the
set of APIs provided in the Microsoft.DotNet.ProjectModel
namespace. These APIs allowed a tool
to read a project.json and discover a list of dependencies, CSharp files, target frameworks, etc.
Migrating from these APIs requires a paradigm shift. The ‘project model’ in the project.json world
was defined entirely by the API in Microsoft.DotNet.ProjectModel
. In an MSBuild project, there
is no definitive description of project behavior. Instead, MSBuild relies on well-known properties and
items.
Step 1. Find the MSBuild project
When a CLI tools begins, Directory.GetCurrentDirectory()
will be the directory containing
the user’s project file. The tool must search this directory for an MSBuild file to target.
One method for this is to search for files ending in *.*proj
.
using System.IO;
using System.Linq;
namespace DotnetNames.Tool
{
class Program
{
public static int Main()
{
var projectFile = Directory.EnumerateFiles(
Directory.GetCurrentDirectory(),
"*.*proj")
.Where(f => !f.EndsWith(".xproj")) // ignore *.xproj files
.FirstOrDefault();
// ...
}
}
}
Another approach is to require a command line flag, such as --project
to specified the MSBuild project file
to be used.
(For an example of a more robust project finder, see dotnet-watch’s MsBuildProjectFinder
class.
Source for MsBuildProjectFinder on GitHub.)
Step 2. Injecting an MSBuild target
Background
Most MSBuild projects (CSharp, Visual Basic), will invoke an Import
that brings in Microsoft.Common.targets
.
Microsoft.Common.targets provides an extensibility point for injecting targets into a file.
You can read the source code for this extensibility point in the Microsoft.Common.targets file. (Source on GitHub.)
<Import Project="$(MSBuildProjectExtensionsPath)$(MSBuildProjectFile).*.targets">
By default, MSBuildProjectExtensionsPath
will be the obj/
folder next to the MSBuild project.
(This step could also be named “abusing MSBuildProjectExtensionsPath”. This extension was originally created for package managers, like NuGet.)
Comments in the source code contain this guidance:
Package management systems will create a file at: $(MSBuildProjectExtensionsPath)$(MSBuildProjectFile).<SomethingUnique>.targets
Each package management system should use a unique moniker to avoid collisions. It is a wild-card import so the package management system can write out multiple files but the order of the import is alphabetic because MSBuild sorts the list.
Using it
To inject a target, our dotnet-names
tool will write a file to match this glob import.
For example, if the tool is running on Web.csproj
, the tool would create a file named
obj/Web.csproj.dotnet-names.targets
.
var targetFileName = Path.GetFileName(projectFile) + ".dotnet-names.targets";
var projectExtPath = Path.Combine(Path.GetDirectoryName(projectFile), "obj");
var targetFile = Path.Combine(projectExtPath, targetFileName);
File.WriteAllText(targetFile, @"
<Project>
<Target Name=""_GetDotNetNames"">
<PropertyGroup>
<_DotNetNamesOutput>
Assembly name: $(AssemblyName)
Root namespace: $(RootNamespace)
Target framework: $(TargetFramework)
</_DotNetNamesOutput>
</PropertyGroup>
<Message Importance=""High"" Text=""$(_DotNetNamesOutput)"" />
</Target>
</Project>
");
Step 3. Invoke the injected target
Now that the tool has injected the target into the user project, it can be invoked by creating a new process that starts MSBuild and invokes this target.
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"msbuild \"{projectFile}\" /t:_GetDotNetNames /nologo"
};
var process = Process.Start(psi);
process.WaitForExit();
Pro-tip: “dotnet” executable, i.e. the “muxer”, is not guaranteed to be in the system PATH variable.
You can find the muxer by using System.AppContext
.
See example implementation on GitHub.
Step 4. Get target output
The sample above created a target that produced a console message from MSBuild. At this point, our program simply prints the output to the command line.
$ dotnet names
Assembly name: My.WebApp
Root namespace: My.WebApp
Target framework: netcoreapp1.0
Most tools will need to something with this information beyond displaying it. As you noticed in Step 2, the tool are creates an MSBuild target inside the user’s project. This target can do anything MSBuild can do, such as producing a file that our tool can read.
Here is updated code for a target that will produce a file for dotnet-names to read:
File.WriteAllText(targetFile,
@"<Project>
<Target Name=""_GetDotNetNames"">
<ItemGroup>
<_DotNetNamesOutput Include=""AssemblyName: $(AssemblyName)"" />
<_DotNetNamesOutput Include=""RootNamespace: $(RootNamespace)"" />
<_DotNetNamesOutput Include=""TargetFramework: $(TargetFramework)"" />
</ItemGroup>
<WriteLinesToFile File=""$(_DotNetNamesFile)"" Lines=""@(_DotNetNamesOutput)"" Overwrite=""true"" />
</Target>
</Project>");
var tmpFile = Path.GetTempFileName();
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"msbuild \"{projectFile}\" /t:_GetDotNetNames /nologo \"/p:_DotNetNamesFile={tmpFile}\""
};
var process = Process.Start(psi);
process.WaitForExit();
if (process.ExitCode != 0)
{
Console.Error.WriteLine("Invoking MSBuild target failed");
return 1;
}
var lines = File.ReadAllLines(tmpFile);
This target will write a line to the file, one line for each item in the _DotNetNamesOutput
item group.
From here, the tool can parse the serialized file to find information it needs.
Altogether
See the end of this blog post for the completed app.
Next steps
With this foundation, you can enhance the tool to gather even more information about a project. Here are some ways to enhance the tool.
- Invoke targets in the build chain. For example, if you want to gather information about dependencies,
your tool might invoke the target
ResolveDependenciesDesignTime
, which can identifyPackageReferences
andProjectReferences
. - Handle multi-targeting projects. If the property
TargetFrameworks
is set, this project is using multiple NuGet frameworks. Your tool target may need to invoke MSBuild multiple times internally to gather full information. - Force a compile. Invoking the target
Build
will cause the project to compile.
Advanced examples of this technique
See https://github.com/aspnet/DotNetTools and https://github.com/aspnet/EntityFramework.Tools
for more examples of the approach explained in this blog post. dotnet-user-secrets
, dotnet-ef
,
and dotnet-watch
gather information from projects using this approach.
Additional comments
Direct project evaluation
Another way to gather information about a project is to load and execute it using MSBuild APIs. Although it may seem like the right approach, my experience with it is that MSBuild APIs are difficult to use correctly. Using MSBuild API has enough negative consequences that I do not recommend it. Those negative consequences include:
- Assembly loading issues. You must ensure your tool will likely run into issues loading all of MSBuild’s dependencies. See https://github.com/Microsoft/msbuild/issues/1097.
- Bloat. Reference MSBuild APIs means your tool effectively includes all of MSBuild and its runtime dependencies. This increases the disk footprint of your tool.
- Assembly conflicts. If your tool needs to load an assembly that is also used by MSBuild or its commonly imported extensions, it is likely your tool will trample the SDK’s version and cause assembly load errors. Common example: JSON.NET is included in the MSBuild SDK because NuGet references it.
But if you still wish to persue this, s simple example of this has already been implemented
by Simone Chiaretta in his tool dotnet-prop
. See https://github.com/simonech/dotnet-prop.
Modifying the project
This method demonstrates a read-only approach to working with a project. To manipulate a project file, your tool will need to use the MSBuild construction APIs. This is beyond the scope of this blog post.
Completed example
Here is the code for the completed dotnet-names
tool.