File: Build\ProjectBuildManager.cs
Web Access
Project: src\roslyn\src\Workspaces\MSBuild\BuildHost\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.csproj (Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Build.Framework;
using Roslyn.Utilities;
using MSB = Microsoft.Build;

namespace Microsoft.CodeAnalysis.MSBuild;

internal sealed class ProjectBuildManager : IDisposable
{
    private static readonly XmlReaderSettings s_xmlReaderSettings = new()
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null
    };

    private static readonly Dictionary<string, string> s_defaultGlobalProperties = new()
    {
        // this will tell msbuild to not build the dependent projects
        { PropertyNames.DesignTimeBuild, bool.TrueString },

        // this will force CoreCompile task to execute even if all inputs and outputs are up to date
#if NET
        { PropertyNames.NonExistentFile, "__NonExistentSubDir__\\__NonExistentFile__" },
#else
        // Setting `BuildingInsideVisualStudio` indirectly sets NonExistentFile:
        // https://github.com/microsoft/msbuild/blob/ab9b2f36a5ff7a85f842b205d5529e77fdc9d7ab/src/Tasks/Microsoft.Common.CurrentVersion.targets#L3462-L3470
        { PropertyNames.BuildingInsideVisualStudio, bool.TrueString },
#endif

        { PropertyNames.BuildProjectReferences, bool.FalseString },
        { PropertyNames.BuildingProject, bool.FalseString },

        // retrieve the command-line arguments to the compiler
        { PropertyNames.ProvideCommandLineArgs, bool.TrueString },

        // don't actually run the compiler
        { PropertyNames.SkipCompilerExecution, bool.TrueString },

        { PropertyNames.ContinueOnError, PropertyValues.ErrorAndContinue },

        // this ensures that the parent project's configuration and platform will be used for
        // referenced projects. So, setting Configuration=Release will also cause any project
        // references to also be built with Configuration=Release. This is necessary for getting
        // a more-likely-to-be-correct output path from project references.
        { PropertyNames.ShouldUnsetParentConfigurationAndPlatform, bool.FalseString }
    };

    public string[] KnownCommandLineParserLanguages { get; }

    private readonly MSB.Evaluation.ProjectCollection _projectCollection;
    private readonly MSBuildDiagnosticLogger _buildLogger = new()
    {
        Verbosity = MSB.Framework.LoggerVerbosity.Normal
    };
    private bool _disposed;

    public ProjectBuildManager(string[] knownCommandLineParserLanguages, Dictionary<string, string> globalProperties, ILogger? msbuildLogger = null, int? maxNodeCount = null)
    {
        KnownCommandLineParserLanguages = knownCommandLineParserLanguages;

        var allProperties = new Dictionary<string, string>(s_defaultGlobalProperties);
        foreach (var kvp in globalProperties)
            allProperties[kvp.Key] = kvp.Value;

        // Pass in the binlog (if any) to the ProjectCollection to ensure evaluation results are included in it.
        //
        // We do not need to include the _buildLogger in the ProjectCollection - it just collects the
        // DiagnosticLog from the build steps, but evaluation already separately reports the DiagnosticLog.
        var loggers = msbuildLogger is not null
            ? new MSB.Framework.ILogger[] { msbuildLogger }
            : Array.Empty<MSB.Framework.ILogger>();

        // Pass empty loggers array to workaround LoggerException when passing binary logger to both evaluation and build. See https://github.com/dotnet/msbuild/issues/11867
        _projectCollection = new MSB.Evaluation.ProjectCollection(allProperties, loggers: [], MSB.Evaluation.ToolsetDefinitionLocations.Default);

        var buildParameters = new MSB.Execution.BuildParameters(_projectCollection)
        {
            // The loggers are not inherited from the project collection, so specify both the
            // binlog logger and the _buildLogger for the build steps.
            Loggers = [.. loggers, _buildLogger],

            // If we have an additional logger and it's diagnostic, then we need to opt into task inputs globally, or otherwise
            // it won't get any log events. This logic matches https://github.com/dotnet/msbuild/blob/fa6710d2720dcf1230a732a8858ffe71bcdbe110/src/Build/Instance/ProjectInstance.cs#L2365-L2371
            LogTaskInputs = msbuildLogger is not null && msbuildLogger.Verbosity == LoggerVerbosity.Diagnostic,

            // Disable node reuse so nodes don't live around once we're done
            EnableNodeReuse = false
        };

        if (maxNodeCount is int nodeCount)
            buildParameters.MaxNodeCount = nodeCount;

        MSB.Execution.BuildManager.DefaultBuildManager.BeginBuild(buildParameters);
    }

    public async Task<(MSB.Evaluation.Project? project, DiagnosticLog log)> LoadProjectAsync(
        string path, CancellationToken cancellationToken)
    {
        Contract.ThrowIfTrue(_disposed);

        var log = new DiagnosticLog();

        try
        {
            var loadedProjects = _projectCollection.GetLoadedProjects(path);
            if (loadedProjects.Count > 0)
            {
                Debug.Assert(loadedProjects.Count == 1);

                return (loadedProjects.First(), log);
            }

            using var stream = FileUtilities.OpenAsyncRead(path);
            using var readStream = new MemoryStream();

            // There is no overload that we can call that takes a cancellationToken but doesn't take a bufferSize; this bufferSize
            // is the default if we call the overload with just a stream.
            await stream.CopyToAsync(readStream, bufferSize: 81920, cancellationToken).ConfigureAwait(false);
            readStream.Position = 0;
            return LoadProjectCore(path, readStream, log);
        }
        catch (Exception e)
        {
            log.Add(e, path);
            return (project: null, log);
        }
    }

    private (MSB.Evaluation.Project? project, DiagnosticLog log) LoadProjectCore(
        string path, Stream readStream, DiagnosticLog log)
    {
        try
        {
            using var xmlReader = XmlReader.Create(readStream, s_xmlReaderSettings);
            var xml = MSB.Construction.ProjectRootElement.Create(xmlReader, _projectCollection);

            // When constructing a project from an XmlReader, MSBuild cannot determine the project file path.  Setting the
            // path explicitly is necessary so that the reserved properties like $(MSBuildProjectDirectory) will work.
            xml.FullPath = path;

            // Roughly matches the VS project load settings for their design time builds.
            var projectLoadSettings = MSB.Evaluation.ProjectLoadSettings.RejectCircularImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreEmptyImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreMissingImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreInvalidImports
                | MSB.Evaluation.ProjectLoadSettings.DoNotEvaluateElementsWithFalseCondition
                | MSB.Evaluation.ProjectLoadSettings.FailOnUnresolvedSdk;

            var project = new MSB.Evaluation.Project(
                xml,
                globalProperties: null,
                toolsVersion: null,
                _projectCollection,
                projectLoadSettings);

            return (project, log);
        }
        catch (Exception e)
        {
            log.Add(e, path);
            return (project: null, log);
        }
    }

    public (MSB.Evaluation.Project? project, DiagnosticLog log) LoadProject(string path, Stream readStream)
    {
        Contract.ThrowIfTrue(_disposed);

        var log = new DiagnosticLog();
        try
        {
            return LoadProjectCore(path, readStream, log);
        }
        catch (Exception e)
        {
            log.Add(e, path);
            return (project: null, log);
        }
    }

    public async Task<string?> TryGetOutputFilePathAsync(
        string path, CancellationToken cancellationToken)
    {
        // This tries to get the project output path and retrieving the evaluated $(TargetPath) property.

        var (project, _) = await LoadProjectAsync(path, cancellationToken).ConfigureAwait(false);
        return project?.GetPropertyValue(PropertyNames.TargetPath);
    }

    public void Dispose()
    {
        if (_disposed)
            return;

        MSB.Execution.BuildManager.DefaultBuildManager.EndBuild();

        // unload project so collection will release global strings
        _projectCollection.UnloadAllProjects();
        _projectCollection.Dispose();
        _disposed = true;
    }

    public async Task<MSB.Execution.ProjectInstance[]> BuildProjectInstancesAsync(
        MSB.Evaluation.Project project, DiagnosticLog log, CancellationToken cancellationToken)
    {
        Contract.ThrowIfTrue(_disposed);

        var targetFrameworkValue = project.GetPropertyValue(PropertyNames.TargetFramework);
        var targetFrameworksValue = project.GetPropertyValue(PropertyNames.TargetFrameworks);

        if (!RoslynString.IsNullOrEmpty(targetFrameworkValue) || RoslynString.IsNullOrEmpty(targetFrameworksValue))
        {
            return [await BuildProjectInstanceAsync(project, log, cancellationToken).ConfigureAwait(false)];
        }

        // This project has a <TargetFrameworks> property, but does not specify a <TargetFramework>.
        // In this case, we need to iterate through the <TargetFrameworks>, set <TargetFramework> with
        // each value, and build the project.

        var targetFrameworks = targetFrameworksValue.Split(';');

        if (!project.GlobalProperties.TryGetValue(PropertyNames.TargetFramework, out var initialGlobalTargetFrameworkValue))
            initialGlobalTargetFrameworkValue = null;

        var results = new List<MSB.Execution.ProjectInstance>(targetFrameworks.Length);
        foreach (var targetFramework in targetFrameworks)
        {
            project.SetGlobalProperty(PropertyNames.TargetFramework, targetFramework);
            project.ReevaluateIfNecessary();

            var projectInstance = await BuildProjectInstanceAsync(project, log, cancellationToken).ConfigureAwait(false);
            results.Add(projectInstance);
        }

        if (initialGlobalTargetFrameworkValue is null)
        {
            project.RemoveGlobalProperty(PropertyNames.TargetFramework);
        }
        else
        {
            project.SetGlobalProperty(PropertyNames.TargetFramework, initialGlobalTargetFrameworkValue);
        }

        project.ReevaluateIfNecessary();

        return results.ToArray();
    }

    private Task<MSB.Execution.ProjectInstance> BuildProjectInstanceAsync(
        MSB.Evaluation.Project project, DiagnosticLog log, CancellationToken cancellationToken)
    {
        var requiredTargets = new[] { TargetNames.Compile, TargetNames.CoreCompile };
        var optionalTargets = new[] { TargetNames.DesignTimeMarkupCompilation };

        return BuildProjectInstanceAsync(project, requiredTargets, optionalTargets, log, cancellationToken);
    }

    private async Task<MSB.Execution.ProjectInstance> BuildProjectInstanceAsync(
        MSB.Evaluation.Project project, string[] requiredTargets, string[] optionalTargets, DiagnosticLog log, CancellationToken cancellationToken)
    {
        // create a project instance to be executed by build engine.
        // The executed project will hold the final model of the project after execution via msbuild.
        var projectInstance = project.CreateProjectInstance();

        // Verify targets
        foreach (var target in requiredTargets)
        {
            if (!projectInstance.Targets.ContainsKey(target))
            {
                log.Add(string.Format(WorkspaceMSBuildBuildHostResources.Project_does_not_contain_0_target, target), projectInstance.FullPath);
                return projectInstance;
            }
        }

        var targets = new List<string>(requiredTargets);
        foreach (var target in optionalTargets)
        {
            if (projectInstance.Targets.ContainsKey(target))
            {
                targets.Add(target);
            }
        }

        var buildRequestData = new MSB.Execution.BuildRequestData(projectInstance, [.. targets]);

        var result = await BuildAsync(buildRequestData, log, cancellationToken).ConfigureAwait(false);

        if (result.OverallResult == MSB.Execution.BuildResultCode.Failure)
        {
            if (result.Exception != null)
            {
                log.Add(result.Exception, projectInstance.FullPath);
            }
        }

        return projectInstance;
    }

    private async Task<MSB.Execution.BuildResult> BuildAsync(MSB.Execution.BuildRequestData requestData, DiagnosticLog log, CancellationToken cancellationToken)
    {
        // MSBuild doesn't have a way to cancel a single submission, so we'll only check the token before we start. In practice this is fine --
        // the RPC layer we use to call into the BuildHost doesn't support cancellation anyways so there's no reason to have lots of extra code.
        cancellationToken.ThrowIfCancellationRequested();

        var submission = MSB.Execution.BuildManager.DefaultBuildManager.PendBuildRequest(requestData);

        try
        {
            // The SubmissionId is assigned by PendBuildRequest and is the same SubmissionId that appears on the
            // BuildEventContext of every event raised while this submission builds.
            _buildLogger.RegisterLog(submission.SubmissionId, log);

            var taskSource = new TaskCompletionSource<MSB.Execution.BuildResult>();

            // Start the job
            submission.ExecuteAsync(sub =>
            {
                // when finished
                try
                {
                    var result = sub.BuildResult;
                    taskSource.TrySetResult(result);
                }
                catch (Exception e)
                {
                    taskSource.TrySetException(e);
                }
            }, null);

            return await taskSource.Task.ConfigureAwait(false);
        }
        finally
        {
            // Ensure the log is cleaned up if it's still there no matter if we take an exceptional path or not
            _buildLogger.TryUnregisterLog(submission.SubmissionId);
        }
    }
}