Programmatic access to project references using MSBuild APIs on .NET and Mono

Robin Neatherway - Tue 07 October 2014 - fsharp, msbuild, .net

UPDATE: This approach has been refined and included into FSharp.Compiler.Service. See the next post for details.

When developing the Emacs support for F# I decided that the simplest way to add project support was to allow people to use the MSBuild .fsproj files they've been used to. As MonoDevelop/Xamarin Studio also support this format, allowing people to try Emacs without having to create a fresh project structure and cleanly interoperate with people using other editors seemed like the most pragmatic approach.

The intellisense information is provided by a backend process -- fsautocomplete -- which in turn provides a text/JSON interface to the FSharp Compiler Service (FCS) library. I'll go into more detail about how this works another time, but for now the important thing is that each request to FCS for a .fs file in a particular project needs to be accompanied by the full paths to the assemblies referenced by that project.

I added this capability in version 0.4 of fsautocomplete, using the MSBuild API, but I found that Mono doesn't support the more modern version of the API so I used the older deprecated API. The snippet below shows how to print all the fully resolved assembly references using FSI, but note that in FSI, compiled against .NET 4.0, only projects targeting .NET 4.0 or earlier will load. The same code works fine for .NET 4.5 projects when compiled.

#r @"Microsoft.Build.Engine.dll"
#r @"Microsoft.Build.dll"

open Microsoft.Build
open System.IO
open System

let log = new BuildEngine.ConsoleLogger()
log.Verbosity <- Framework.LoggerVerbosity.Quiet
let eng = new BuildEngine.Engine()
do eng.RegisterLogger(log)

let projectFile = Path.GetFullPath
                   "FSharp.AutoComplete/FSharp.AutoComplete.fsproj"

let p = new BuildEngine.Project(eng)
do p.Load(projectFile)
let b = p.Build("ResolveAssemblyReferences")

for i in p.GetEvaluatedItemsByName("ResolvedFiles") do
  Console.WriteLine i.FinalItemSpec

for i in p.GetEvaluatedItemsByName("ProjectReference") do
  let referencedProject = Path.Combine(Path.GetDirectoryName projectFile,
                                       i.FinalItemSpec)
  let p' = new BuildEngine.Project()
  do p'.Load(referencedProject)
  Path.Combine(Path.GetDirectoryName projectFile,
               p'.GetEvaluatedProperty "OutDir",
               p'.GetEvaluatedProperty "TargetFileName")
  |> Console.WriteLine

After the Build call, the first for loop prints the fully resolved names of each explicitly referenced assembly, while the second loads any referenced projects in turn to include the output assembly of that project.

For this example I modified FSharp.AutoComplete.fsproj to target .NET 4.0 and on Linux this gives the following output:

/usr/lib/mono/4.0/mscorlib.dll
/usr/lib/mono/4.0/System.dll
/usr/lib/mono/4.0/System.Xml.dll
/usr/lib/mono/4.0/System.Numerics.dll
/usr/lib/mono/4.0/Microsoft.Build.dll
/usr/lib/mono/4.0/Microsoft.Build.Engine.dll
/usr/lib/mono/4.0/Microsoft.Build.Framework.dll
/usr/lib/mono/4.0/Microsoft.Build.Tasks.v4.0.dll
/usr/lib/mono/4.0/Microsoft.Build.Utilities.v4.0.dll
../lib/ndesk-options/NDesk.Options.dll
../lib/newtonsoft.json/Newtonsoft.Json.dll
/usr/lib/mono/4.0/FSharp.Core.dll
/auto/users/robnea/dev/rneatherway-fsharpbinding/FSharp.AutoComplete/bin/Debug/FSharp.Compiler.Service.dll
/usr/lib/mono/4.0/System.Core.dll
/auto/users/robnea/dev/rneatherway-fsharpbinding/FSharp.AutoComplete/bin/Debug/FSharp.CompilerBinding.dll

These fully resolved references, which include the FSharp.CompilerBinding project library, are exactly the context FCS needs for its analysis of the project in question.

Now, although this works well on Mono, the deprecated API now doesn't work well on .NET, so I had to prepare a different version for Windows:

#r @"Microsoft.Build.dll"
#r @"Microsoft.Build.Framework.dll"

open Microsoft.Build

let l = new Logging.ConsoleLogger() :> Framework.ILogger
l.Verbosity <- Framework.LoggerVerbosity.Quiet

let p = new Execution.ProjectInstance(
              @"FSharp.AutoComplete/FSharp.AutoComplete.fsproj")

let b = p.Build([|"ResolveAssemblyReferences"|], [l])

for i in p.GetItems("ReferencePath") do
  Console.WriteLine i.EvaluatedInclude

for cp in p.GetItems("ProjectReference") do
  let p' = new Execution.ProjectInstance(cp.GetMetadataValue("FullPath"))
  Console.WriteLine (p'.GetPropertyValue("TargetPath"))

Which gives the expected output:

h:\winprogs\rneatherway-fsharpbinding\fsharp.autocomplete\bin\Debug\FSharp.Compiler.Service.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\FSharp\3.0\Runtime\v4.0\FSharp.Core.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Microsoft.Build.dll
...

For both APIs, I've included a console logger you can tweak the verbosity of if you need to debug the build. You've probably also noticed by this point that the MSBuild Item and Property values we extract are not consistent across the two APIs. To determine which you should use, find Microsoft.Common.targets on your machine (either lib/mono/xbuild/12.0/bin or various locations on Windows). Identify the task and the output you are interested in (here the output ResolvedFiles of ResolveAssemblyReferences). This output is a child of <ResolveAssemblyReference>:

<Output TaskParameter="ResolvedFiles" ItemName="ReferencePath"/>

For the old API, use the value of the TaskParameter attribute, i.e. ResolvedFiles, for the new API use the ItemName attribute, i.e. ReferencePath.

For more details, check out the implementation as part of FSharp.AutoComplete in MonoProjectParser.fs and DotNetProjectParser.fs.