rendle.dev

Mark Rendle's .NET development blog.

5-Minute Read

First up, let me just say that Roslyn, the compiler platform for C# and .NET, is amazing and I love it. I couldn’t have built Visual ReCode without it. But I have run into an issue with the MSBuildWorkspace type that, ostensibly, lets you load .NET solutions quickly and easily.

The problem is, when you use MSBuildWorkspace in a .NET 4.x project, it uses the old, full-fat .NET version of MSBuild. But when you use it in a .NET Core 3.1 project, it uses .NET Core’s version of MSBuild, and they’re not compatible. Specifically, because the MSBuildWorkspace internals fully parse the project file and resolve all the targets and everything, when .NET Core’s MSBuild tries to load the .NET assemblies used in old projects' targets, it all kind of explodes.

Msbuild failed when processing the file 'D:\ReCode\Samples\Hotel\Hotel\Hotel.csproj' with message: 
C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\Microsoft.Common.CurrentVersion.targets: (1489, 5): 
The "AssignProjectConfiguration" task could not be instantiated from 
"Microsoft.Build.Tasks.Core, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a".
Method not found: 'Byte[] System.AppDomainSetup.GetConfigurationBytes()'.

Why this is annoying me

This is annoying me because I love .NET Core and I want to use it for everything. Right now, the Visual ReCode engine is all in the same solution as the Visual Studio extension, which means it’s written in C# 7.3 for .NET 4.7.2, and there’s a whole world of useful stuff I don’t have access to: nullable reference types, proper Span support, IAsyncEnumerable, and even a bunch of new APIs that I’d forgotten were new to .NET Core like Path.GetFullPath.

So I’m extracting the engine from the extension, with the plan to run it as an out-of-process server, using (of course) gRPC to talk to the VS extension. Doing this also solves a bunch of other problems like getting out of Visual Studio’s weird Task scheduler, running in a 32-bit process alongside whatever other extensions are installed, and having to spin up a whole new VS instance to do a little debugging.

But my .NET Core 3.1 gRPC app can’t use MSBuildWorkspace to load old-style projects, like, oh, WCF projects, which ReCode kinda needs to work with.

A solution

There is another Workspace implementation: AdhocWorkspace. This is a much more tolerant and forgiving workspace, but it takes a little more effort to use. I already use it extensively in unit tests, where I create solutions, projects and documents in memory from strings. The tests happily pass when running in .NET Core 3.1, so I thought, why don’t I just create an AdhocWorkspace from an actual solution and its projects? All I need to do is parse the .sln file and the .csproj files and build the thing up manually.

It’s a well-known fact that the Visual Studio solution file is weird and hard to parse, but if all you’re doing is looking for projects it’s actually not that bad. A Project entry looks like this:

Project(""{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"") = ""Hotel"", ""Hotel\Hotel.csproj"", ""{0AB9EB14-38A1-40FD-B093-B756E9679FE5}""
EndProject

Se we can just look for lines that start with Project, skip past the = symbol and grab the next two quoted values, which are the name of the project and the path relative to the solution file.

With the path to the csproj file we can open that up and parse it using XDocument. If the project is the old-style csproj, the top-level <Project> element will have an xmlns attribute, which we’ll need to use to find elements.

var itemGroupName = XName.Get("ItemGroup", xmlns);
var compileName = XName.Get("Compile", xmlns);

All the C# files (or “Documents” as Roslyn calls them) will be in <Compile> elements

<ItemGroup>
  <Compile Include=""HotelService.svc.cs"">
    <DependentUpon>HotelService.svc</DependentUpon>
  </Compile>
  <Compile Include=""IHotelService.cs"" />
  <Compile Include=""Properties\AssemblyInfo.cs"" />
</ItemGroup>

We also need to handle references, both to other projects in the solution, and to .NET assemblies which might be in a packages folder, the Global Assembly Cache, or just stored somewhere on disk. Project references are in <ProjectReference> elements, and all the others, including to NuGet packages, are in <Reference> elements, with a <HintPath> element if they’re not in the GAC.

Loading these into an AdhocWorkspace is done using Roslyn’s *Info classes, which are like builders for Solutions, Projects and Documents. For each document, we create a DocumentInfo object:

var di = DocumentInfo.Create(DocumentId.CreateNewId(projectId), documentSource.Name,
    filePath: documentSource.Path,
    loader: new FileTextLoader(documentSource.Path, null));

With a list of all the documents, as well as the Project and Assembly references we got from the project file, we can create the ProjectInfo object:

return ProjectInfo.Create(projectId, VersionStamp.Default, projectSource.Name, projectSource.Name, LanguageNames.CSharp,
    projectSource.Path,
    projectReferences: projectReferences,
    metadataReferences: metadataReferences,
    documents: documents);

And with all our ProjectInfo objects we can create a SolutionInfo and add it to our AdhocWorkspace:

var workspace = new AdhocWorkspace();
var solutionInfo = SolutionInfo.Create(SolutionId.CreateNewId(),
                    VersionStamp.Default, solutionFilePath, projectInfos);
workspace.AddSolution(solutionInfo);

This will turn all those Infos into actual Roslyn projects and documents that we can poke around in using SyntaxNodes and Symbols in our .NET Core 3.1 application.

New “SDK-style” Projects

Some projects in the solution might be the new SDK-style that came out of .NET Core but can be used for .NET 4.x projects as well. These are much simpler than the old-style projects, but actually harder to work with in this instance because all the default references (e.g. mscorlib, System.Runtime, etc) are inferred from the SDK. In this instance you can actually use MSBuildWorkspace to load the project up and then just grab the Document, ProjectReference and MetadataReference info and copy it into the AdhocWorkspace so everything works together.

It’s on NuGet

I’ve wrapped all this up in a NuGet package, RendleLabs.LegacyWorkspaceLoader, and the source code is at github.com/RendleLabs/LegacyWorkspaceLoader.

In its current state, it works for my use case, but I’m sure there are edge cases that it doesn’t catch. If I run into any while using it in Visual ReCode I’ll fix them up and update the package, but if you take it for a spin and run into problems, please create an Issue on the GitHub repo or send a pull request.


Say Something

Comments

Keith R's Gravatar
“First let me just say that Roslyn…is amazing and I love it.” That makes one of us. I’ll preface that .NET is amazing and I love it, especially Core becoming super fast and truly multi-platform. But Roslyn? It’s huge, klunky, unwieldy, and poorly documented. Maybe it’s easy for an expert, but it’s harrrrd to become an expert. Every time I use it I run into serious roadblocks which just shouldn’t be there. The latest frustration with it, whose search for solution brought me here, is this simple case: Load a solution (with MSBuildWorkspace), walk the projects, and obtain paths to their project references. Simple right? The path is there in the ProjectId, but some designer thought, “No one needs this but us; make that internal.” Now I have to access an internal feature via reflection. This, and so many other things with Roslyn, just shouldn’t be that hard.

Recent Posts

Categories

About

Mark Rendle's blog about making software and stuff.