Skip to content

Instantly share code, notes, and snippets.

@tintoy
Last active July 3, 2020 07:47
Show Gist options
  • Save tintoy/76b48591e2a34429c1f5c02556b8b4d2 to your computer and use it in GitHub Desktop.
Save tintoy/76b48591e2a34429c1f5c02556b8b4d2 to your computer and use it in GitHub Desktop.
Sketch out an alternative to Roslyn's MSBuildWorkspace (which isn't available on .NET Core / .NET Standard yet).
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Serilog;
using Serilog.Events;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using MSB = Microsoft.Build.Evaluation;
using MSBC = Microsoft.Build.Construction;
using MSBE = Microsoft.Build.Execution;
using MSBF = Microsoft.Build.Framework;
namespace RoslynWS
{
static class Program
{
static readonly FileInfo SolutionFile = new FileInfo(@"D:\Development\test-projects\RoslynWS\RoslynWS.sln");
static void Main()
{
SynchronizationContext.SetSynchronizationContext(
new SynchronizationContext()
);
ConfigureLogging();
try
{
Log.Information("First run...");
DateTime then = DateTime.Now;
Load();
Log.Information("First run complete ({RunTimeMS}ms).", (DateTime.Now - then).TotalMilliseconds);
Log.Information("Second run...");
then = DateTime.Now;
Load();
Log.Information("Second run complete ({RunTimeMS}ms).", (DateTime.Now - then).TotalMilliseconds);
}
catch (Exception unexpectedError)
{
Log.Error(unexpectedError, "Unexpected error.");
}
}
static void Load()
{
Log.Verbose("Creating workspace...");
AdhocWorkspace ws = new AdhocWorkspace();
Log.Verbose("Initialising solution...");
Solution solution = ws.AddSolution(SolutionInfo.Create(
SolutionId.CreateNewId(),
VersionStamp.Create(),
filePath: SolutionFile.FullName
));
Log.Verbose("Loading solution...");
MSBC.SolutionFile solutionFile = MSBC.SolutionFile.Parse(SolutionFile.FullName);
Log.Verbose("Initialising project collection...");
MSB.ProjectCollection projectCollection = MSBuildHelper.CreateProjectCollection(
solutionDirectory: SolutionFile.Directory.FullName
);
Log.Verbose("Loading projects...");
foreach (var solutionProject in solutionFile.ProjectsInOrder)
{
var projectId = ProjectId.CreateNewId();
var msbuildProject = projectCollection.LoadProject(solutionProject.AbsolutePath);
List<DocumentInfo> projectDocuments = new List<DocumentInfo>();
foreach (MSB.ProjectItem item in msbuildProject.GetItems("Compile"))
{
string itemPath = item.GetMetadataValue("FullPath");
projectDocuments.Add(
DocumentInfo.Create(
DocumentId.CreateNewId(projectId),
name: Path.GetFileName(itemPath),
filePath: itemPath,
loader: TextLoader.From(
TextAndVersion.Create(
SourceText.From(File.ReadAllText(itemPath)),
VersionStamp.Create()
)
)
)
);
}
Log.Information("Resolving assembly references for project {ProjectName}...",
Path.GetFileName(msbuildProject.FullPath)
);
List<MetadataReference> references = new List<MetadataReference>();
foreach (string assemblyPath in ResolveReferences(msbuildProject))
{
references.Add(
MetadataReference.CreateFromFile(assemblyPath, MetadataReferenceProperties.Assembly)
);
}
solution = solution.AddProject(ProjectInfo.Create(
projectId,
VersionStamp.Create(),
name: Path.GetFileNameWithoutExtension(msbuildProject.FullPath),
assemblyName: Path.GetFileNameWithoutExtension(msbuildProject.FullPath),
language: "C#",
filePath: msbuildProject.FullPath,
outputFilePath: msbuildProject.GetPropertyValue("TargetPath"),
documents: projectDocuments,
metadataReferences: references
));
var project = solution.GetProject(projectId);
solution = solution.WithProjectCompilationOptions(projectId, project.CompilationOptions.WithSpecificDiagnosticOptions(
new Dictionary<string, ReportDiagnostic>
{
["CS1701"] = ReportDiagnostic.Suppress
}
));
ws.TryApplyChanges(solution);
}
Log.Verbose("Compiling first project...");
var prj = ws.CurrentSolution.Projects.First();
Compilation compilation = prj.GetCompilationAsync().Result;
if (compilation != null)
{
var diagnostics = compilation.GetDiagnostics();
Log.Information("Compiled first project; {DiagnosticCount} diagnostics.", diagnostics.Length);
foreach (var diagnostic in diagnostics)
{
if (diagnostic.IsSuppressed || diagnostic.Severity == DiagnosticSeverity.Hidden)
continue;
switch (diagnostic.Severity)
{
case DiagnosticSeverity.Info:
{
Log.Information("Diagnostic: {Diagnostic}", diagnostic);
break;
}
case DiagnosticSeverity.Warning:
{
Log.Warning("Diagnostic: {Diagnostic}", diagnostic);
break;
}
case DiagnosticSeverity.Error:
{
Log.Error("Diagnostic: {Diagnostic}", diagnostic);
break;
}
}
}
INamedTypeSymbol programSymbol = compilation.Assembly.GetTypeByMetadataName("RoslynWS.Program");
if (programSymbol != null)
Log.Information("Found Program symbol.");
else
Log.Warning("Program symbol not found.");
}
else
Log.Warning("Failed to compile first project.");
}
static IEnumerable<string> ResolveReferences(MSB.Project msbuildProject)
{
MSBE.ProjectInstance snapshot = msbuildProject.CreateProjectInstance();
IDictionary<string, MSBE.TargetResult> outputs;
if (!snapshot.Build(new string[] { "ResolveAssemblyReferences" }, null, out outputs))
{
Log.Warning("Failed to build resolver targets.");
yield break;
}
foreach (string targetName in outputs.Keys)
{
MSBE.TargetResult targetResult = outputs[targetName];
MSBF.ITaskItem[] items = targetResult.Items.ToArray();
foreach (MSBF.ITaskItem item in items)
yield return item.GetMetadata("FullPath");
}
}
static void ConfigureLogging()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.Enrich.FromLogContext()
.WriteTo.Debug(
restrictedToMinimumLevel: LogEventLevel.Verbose
)
.WriteTo.LiterateConsole(
restrictedToMinimumLevel: LogEventLevel.Verbose,
outputTemplate: "[{Level:l}] {Message:l}{NewLine}{Exception}"
)
.CreateLogger();
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build" Version="15.3.409" />
<PackageReference Include="Microsoft.Build.Runtime" Version="15.3.409" />
<PackageReference Include="Microsoft.CodeAnalysis" Version="2.3.2" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="2.3.2" />
<PackageReference Include="Serilog" Version="2.5.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="1.0.0" />
<PackageReference Include="Serilog.Sinks.Literate" Version="3.0.0" />
<PackageReference Include="System.Runtime" Version="4.3.0" />
</ItemGroup>
</Project>
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
namespace RoslynWS
{
/// <summary>
/// Information about the .NET Core runtime.
/// </summary>
public class DotNetRuntimeInfo
{
/// <summary>
/// A cache of .NET runtime information by target directory.
/// </summary>
static readonly ConcurrentDictionary<string, DotNetRuntimeInfo> _cache = new ConcurrentDictionary<string, DotNetRuntimeInfo>();
/// <summary>
/// The .NET Core version.
/// </summary>
public string Version { get; set; }
/// <summary>
/// The .NET Core base directory.
/// </summary>
public string BaseDirectory { get; set; }
/// <summary>
/// The current runtime identifier (RID).
/// </summary>
public string RID { get; set; }
/// <summary>
/// Get information about the current .NET Core runtime.
/// </summary>
/// <param name="baseDirectory">
/// An optional base directory where dotnet.exe should be run (this may affect the version it reports due to global.json).
/// </param>
/// <returns>
/// A <see cref="DotNetRuntimeInfo"/> containing the runtime information.
/// </returns>
public static DotNetRuntimeInfo GetCurrent(string baseDirectory = null)
{
return _cache.GetOrAdd(baseDirectory, _ =>
{
DotNetRuntimeInfo runtimeInfo = new DotNetRuntimeInfo();
Process dotnetInfoProcess = Process.Start(new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = baseDirectory,
Arguments = "--info",
UseShellExecute = false,
RedirectStandardOutput = true
});
using (dotnetInfoProcess)
{
dotnetInfoProcess.WaitForExit();
string currentSection = null;
string currentLine;
while ((currentLine = dotnetInfoProcess.StandardOutput.ReadLine()) != null)
{
if (String.IsNullOrWhiteSpace(currentLine))
continue;
if (!currentLine.StartsWith(" "))
{
currentSection = currentLine;
continue;
}
string[] property = currentLine.Split(new char[] { ':' }, count: 2);
if (property.Length != 2)
continue;
property[0] = property[0].Trim();
property[1] = property[1].Trim();
switch (currentSection)
{
case "Product Information:":
{
switch (property[0])
{
case "Version":
{
runtimeInfo.Version = property[1];
break;
}
}
break;
}
case "Runtime Environment:":
{
switch (property[0])
{
case "Base Path":
{
runtimeInfo.BaseDirectory = property[1];
break;
}
case "RID":
{
runtimeInfo.RID = property[1];
break;
}
}
break;
}
}
}
}
return runtimeInfo;
});
}
/// <summary>
/// Clear the cache of .NET runtime information.
/// </summary>
public static void ClearCache()
{
_cache.Clear();
}
}
}
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Exceptions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Collections.Immutable;
namespace RoslynWS
{
/// <summary>
/// Helper methods for working with MSBuild projects.
/// </summary>
public static class MSBuildHelper
{
/// <summary>
/// The names of well-known item metadata.
/// </summary>
public static readonly ImmutableSortedSet<string> WellknownMetadataNames =
ImmutableSortedSet.Create(
"FullPath",
"RootDir",
"Filename",
"Extension",
"RelativeDir",
"Directory",
"RecursiveDir",
"Identity",
"ModifiedTime",
"CreatedTime",
"AccessedTime"
);
/// <summary>
/// Create an MSBuild project collection.
/// </summary>
/// <param name="solutionDirectory">
/// The base (i.e. solution) directory.
/// </param>
/// <returns>
/// The project collection.
/// </returns>
public static ProjectCollection CreateProjectCollection(string solutionDirectory)
{
return CreateProjectCollection(solutionDirectory,
DotNetRuntimeInfo.GetCurrent(solutionDirectory)
);
}
/// <summary>
/// Create an MSBuild project collection.
/// </summary>
/// <param name="solutionDirectory">
/// The base (i.e. solution) directory.
/// </param>
/// <param name="runtimeInfo">
/// Information about the current .NET Core runtime.
/// </param>
/// <returns>
/// The project collection.
/// </returns>
public static ProjectCollection CreateProjectCollection(string solutionDirectory, DotNetRuntimeInfo runtimeInfo)
{
if (String.IsNullOrWhiteSpace(solutionDirectory))
throw new ArgumentException("Argument cannot be null, empty, or entirely composed of whitespace: 'baseDir'.", nameof(solutionDirectory));
if (runtimeInfo == null)
throw new ArgumentNullException(nameof(runtimeInfo));
if (String.IsNullOrWhiteSpace(runtimeInfo.BaseDirectory))
throw new InvalidOperationException("Cannot determine base directory for .NET Core.");
Dictionary<string, string> globalProperties = CreateGlobalMSBuildProperties(runtimeInfo, solutionDirectory);
EnsureMSBuildEnvironment(globalProperties);
ProjectCollection projectCollection = new ProjectCollection(globalProperties) { IsBuildEnabled = false };
// Override toolset paths (for some reason these point to the main directory where the dotnet executable lives).
Toolset toolset = projectCollection.GetToolset("15.0");
toolset = new Toolset(
toolsVersion: "15.0",
toolsPath: globalProperties["MSBuildExtensionsPath"],
projectCollection: projectCollection,
msbuildOverrideTasksPath: ""
);
projectCollection.AddToolset(toolset);
return projectCollection;
}
/// <summary>
/// Create global properties for MSBuild.
/// </summary>
/// <param name="runtimeInfo">
/// Information about the current .NET Core runtime.
/// </param>
/// <param name="solutionDirectory">
/// The base (i.e. solution) directory.
/// </param>
/// <returns>
/// A dictionary containing the global properties.
/// </returns>
public static Dictionary<string, string> CreateGlobalMSBuildProperties(DotNetRuntimeInfo runtimeInfo, string solutionDirectory)
{
if (runtimeInfo == null)
throw new ArgumentNullException(nameof(runtimeInfo));
if (String.IsNullOrWhiteSpace(solutionDirectory))
throw new ArgumentException("Argument cannot be null, empty, or entirely composed of whitespace: 'solutionDirectory'.", nameof(solutionDirectory));
if (solutionDirectory.Length > 0 && solutionDirectory[solutionDirectory.Length - 1] != Path.DirectorySeparatorChar)
solutionDirectory += Path.DirectorySeparatorChar;
return new Dictionary<string, string>
{
[WellKnownPropertyNames.DesignTimeBuild] = "true",
[WellKnownPropertyNames.BuildProjectReferences] = "false",
[WellKnownPropertyNames.ResolveReferenceDependencies] = "true",
[WellKnownPropertyNames.SolutionDir] = solutionDirectory,
[WellKnownPropertyNames.MSBuildExtensionsPath] = runtimeInfo.BaseDirectory,
[WellKnownPropertyNames.MSBuildSDKsPath] = Path.Combine(runtimeInfo.BaseDirectory, "Sdks"),
[WellKnownPropertyNames.RoslynTargetsPath] = Path.Combine(runtimeInfo.BaseDirectory, "Roslyn")
};
}
/// <summary>
/// Ensure that environment variables are populated using the specified MSBuild global properties.
/// </summary>
/// <param name="globalMSBuildProperties">
/// The MSBuild global properties
/// </param>
public static void EnsureMSBuildEnvironment(Dictionary<string, string> globalMSBuildProperties)
{
if (globalMSBuildProperties == null)
throw new ArgumentNullException(nameof(globalMSBuildProperties));
// Kinda sucks that the simplest way to get MSBuild to resolve SDKs correctly is using environment variables, but there you go.
Environment.SetEnvironmentVariable(
WellKnownPropertyNames.MSBuildExtensionsPath,
globalMSBuildProperties[WellKnownPropertyNames.MSBuildExtensionsPath]
);
Environment.SetEnvironmentVariable(
WellKnownPropertyNames.MSBuildSDKsPath,
globalMSBuildProperties[WellKnownPropertyNames.MSBuildSDKsPath]
);
}
/// <summary>
/// Does the specified property name represent a private property?
/// </summary>
/// <param name="propertyName">
/// The property name.
/// </param>
/// <returns>
/// <c>true</c>, if the property name starts with an underscore; otherwise, <c>false</c>.
/// </returns>
public static bool IsPrivateProperty(string propertyName) => propertyName?.StartsWith("_") ?? false;
/// <summary>
/// Does the specified metadata name represent a private property?
/// </summary>
/// <param name="metadataName">
/// The metadata name.
/// </param>
/// <returns>
/// <c>true</c>, if the metadata name starts with an underscore; otherwise, <c>false</c>.
/// </returns>
public static bool IsPrivateMetadata(string metadataName) => metadataName?.StartsWith("_") ?? false;
/// <summary>
/// Does the specified item type represent a private property?
/// </summary>
/// <param name="itemType">
/// The item type.
/// </param>
/// <returns>
/// <c>true</c>, if the item type starts with an underscore; otherwise, <c>false</c>.
/// </returns>
public static bool IsPrivateItemType(string itemType) => itemType?.StartsWith("_") ?? false;
/// <summary>
/// Determine whether the specified metadata name represents well-known (built-in) item metadata.
/// </summary>
/// <param name="metadataName">
/// The metadata name.
/// </param>
/// <returns>
/// <c>true</c>, if <paramref name="metadataName"/> represents well-known item metadata; otherwise, <c>false</c>.
/// </returns>
public static bool IsWellKnownItemMetadata(string metadataName) => WellknownMetadataNames.Contains(metadataName);
/// <summary>
/// Create a copy of the project for caching.
/// </summary>
/// <param name="project">
/// The MSBuild project.
/// </param>
/// <returns>
/// The project copy (independent of original, but sharing the same <see cref="ProjectCollection"/>).
/// </returns>
/// <remarks>
/// You can only create a single cached copy for a given project.
/// </remarks>
public static Project CloneAsCachedProject(this Project project)
{
if (project == null)
throw new ArgumentNullException(nameof(project));
ProjectRootElement clonedXml = project.Xml.DeepClone();
Project clonedProject = new Project(clonedXml, project.GlobalProperties, project.ToolsVersion, project.ProjectCollection);
clonedProject.FullPath = Path.ChangeExtension(project.FullPath,
".cached" + Path.GetExtension(project.FullPath)
);
return clonedProject;
}
/// <summary>
/// The names of well-known MSBuild properties.
/// </summary>
public static class WellKnownPropertyNames
{
/// <summary>
/// The "MSBuildExtensionsPath" property.
/// </summary>
public static readonly string MSBuildExtensionsPath = "MSBuildExtensionsPath";
/// <summary>
/// The "MSBuildSDKsPath" property.
/// </summary>
public static readonly string MSBuildSDKsPath = "MSBuildSDKsPath";
/// <summary>
/// The "SolutionDir" property.
/// </summary>
public static readonly string SolutionDir = "SolutionDir";
/// <summary>
/// The "_ResolveReferenceDependencies" property.
/// </summary>
public static readonly string ResolveReferenceDependencies = "_ResolveReferenceDependencies";
/// <summary>
/// The "DesignTimeBuild" property.
/// </summary>
public static readonly string DesignTimeBuild = "DesignTimeBuild";
/// <summary>
/// The "BuildProjectReferences" property.
/// </summary>
public static readonly string BuildProjectReferences = "BuildProjectReferences";
/// <summary>
/// The "RoslynTargetsPath" property.
/// </summary>
public static readonly string RoslynTargetsPath = "RoslynTargetsPath";
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment