MSBuild is certainly on its way back (if was ever in demise). .NET Core will use it as its build tooling (going away from project.json and .xproj), of course it is still the build tooling for the “full” .NET framework and associated languages and also for native C++ projects. Furthermore it is open source since some time now, under the liberal MIT license.
Recently, I’ve being toying around with the idea to convert ItemGroup items into something else. On the way I needed to convert items using property and item functions and some other goo.
While the result (and even original intention) remains debatable, I thought it is a nice showcase for what could be done in MSBuild, without requiring custom tasks. If nothing more, as always, it is my personal bookkeeping of a technique (transforming ItemGroup items) that I now have written down for future reference.
OK, the setup is a as follows: I have a number Roslyn analyzers, provided as NuGet packages, that are
listed in the Analyzer
ItemGroup. In MSBuild syntax that item group looks like this:
<ItemGroup>
<Analyzer Include="$(SourcesRootPath)\tools\my.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\Desktop.Analyzers.1.1.0\analyzers\dotnet\cs\Desktop.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\Desktop.Analyzers.1.1.0\analyzers\dotnet\cs\Desktop.CSharp.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\Microsoft.AnalyzerPowerPack.1.1.0\analyzers\dotnet\cs\Microsoft.AnalyzerPowerPack.Common.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\Microsoft.AnalyzerPowerPack.1.1.0\analyzers\dotnet\cs\Microsoft.AnalyzerPowerPack.CSharp.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Runtime.Analyzers.1.1.0\analyzers\dotnet\cs\System.Runtime.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Runtime.Analyzers.1.1.0\analyzers\dotnet\cs\System.Runtime.CSharp.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Runtime.InteropServices.Analyzers.1.1.0\analyzers\dotnet\cs\System.Runtime.InteropServices.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Runtime.InteropServices.Analyzers.1.1.0\analyzers\dotnet\cs\System.Runtime.InteropServices.CSharp.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Security.Cryptography.Hashing.Algorithms.Analyzers.1.1.0\analyzers\dotnet\cs\System.Security.Cryptography.Hashing.Algorithms.Analyzers.dll" />
<Analyzer Include="$(SourcesRootPath)\.packages\System.Security.Cryptography.Hashing.Algorithms.Analyzers.1.1.0\analyzers\dotnet\cs\System.Security.Cryptography.Hashing.Algorithms.CSharp.Analyzers.dll" />
</ItemGroup>
Now, what I wanted to do is write a Target, that can read this ItemGroup and call nuget.exe install
for those analyzers
that originate from a NuGet package (note the my.dll
item, which is built locally, and should thus be ignored here).
First we need some definitions/properties:
<PropertyGroup>
<NuGetExe Condition="'$(NuGetExe)' == ''">$(MSBuildThisFileDirectory)\.nuget\nuget.exe</NuGetExe>
<PackageDir>.packages\</PackageDir>
<FullPackageDir>$(MSBuildThisFileDirectory)\$(PackageDir)</FullPackageDir>
<PackageSource Condition="'$(PackageSource)' == ''">https://api.nuget.org/v3/index.json</PackageSource>
</PropertyGroup>
Then, we need the actual Target that should install packages. Note that the following target, due to
MSBuild batching will be called once for
every item in the Analyzer
ItemGroup. This is actually the clue here, because modifying or transforming
one item at a time is much simpler then always attempting to deal with the complete ItemGroup as a whole
(e.g. using %{Analyzer.<Metadata>}
or @(Analyzer->...)
constructs, which don’t nest by the way).
<Target Name="RestoreAnalyzer" Inputs="%(Analyzer.Identity)" Outputs="%(Analyzer.Identity)">
<PropertyGroup>
<_Identity>%(Analyzer.Identity)</_Identity>
</PropertyGroup>
<Message Importance="low"
Text="Ignoring analyzer $(_Identity) which does not seem to be a NuGet package based one."
Condition="$(_Identity.Contains($(PackageDir))) == False"/>
<!--
All of the following has a condition of "looks like a nuget package analyzer".
This is important, because all "calculations" will fall appart if not.
-->
<PropertyGroup Condition="$(_Identity.Contains($(PackageDir)))">
<!--
Extract the following part from the each of the Analyzer-ItemGroup items:
<Analyzer Include="$(SourcesRootPath)\.packages\Desktop.Analyzers.1.1.0\analyzers\dotnet\cs\Desktop.Analyzers.dll" />
^^^^^^^^^^^^^^^^^^^^^^^
-->
<_StartPos>$(_Identity.IndexOf('$(PackageDir)'))</_StartPos>
<_LookupPos>$([MSBuild]::Add( $(_StartPos), $(PackageDir.Length)))</_LookupPos>
<_EndPos>$(_Identity.IndexOf('\', $(_LookupPos) ))</_EndPos>
<_Len>$([MSBuild]::Subtract( $(_EndPos), $(_LookupPos)))</_Len>
<_SpecName>$(_Identity.Substring($(_LookupPos), $(_Len)))</_SpecName>
<!--
Then split this (e.g. Desktop.Analyzers.1.1.0) into version (1.1.0) and package name (Desktop.Analyzers).
Note: the following will fail, if the package name itself contains a number - very wonky.
-->
<_VersionChars>1,2,3,4,5,6,7,8,9,0</_VersionChars>
<_VersionChars>$(_VersionChars.Split(','))</_VersionChars>
<_VerStartPos>$(_SpecName.IndexOfAny($(_VersionChars)))</_VerStartPos>
<_Version>$(_SpecName.Substring($(_VerStartPos)))</_Version>
<_PackageName>$(_SpecName.Substring(0, $([MSBuild]::Subtract($(_VerStartPos),1))))</_PackageName>
</PropertyGroup>
<Error Condition="$(_Identity.Contains($(PackageDir))) and '$(_Version)' == ''" Text="Could not determine package version from $(_Identity)."/>
<Error Condition="$(_Identity.Contains($(PackageDir))) and '$(_PackageName)' == ''" Text="Could not determine package name from $(_Identity)."/>
<Message Condition="$(_Identity.Contains($(PackageDir)))" Importance="high" Text="$(_Version) - $(_PackageName)"/>
<Exec Condition="$(_Identity.Contains($(PackageDir)))" Command='"$(NuGetExe)" install $(_PackageName) -Version $(_Version) -OutputDirectory "$(FullPackageDir.TrimEnd("\"))"'/>
</Target>
</Project>
See, that was easy ;-)
The only thing left to do, is to add this target as a dependency where required. In my case that was the
master “Build” target in the obligatory <topleveldir>\build.proj
file:
<Target Name="Build" DependsOnTargets="RestoreAnalyzer">
<!-- ... other target stuff ... -->
</Target>
Again, note that the target is a simple dependency albeit it will be called multiple times, once for
each entry in the Analyzer
ItemGroup (again, due to MSBuild batching).