File: Build\EvaluationResult.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\Watch\Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.Watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class EvaluationResult(
    LoadedProjectGraph projectGraph,
    IReadOnlyDictionary<ProjectInstanceId, ProjectInstance> restoredProjectInstances,
    IReadOnlyDictionary<string, FileItem> files,
    IReadOnlyDictionary<ProjectInstanceId, StaticWebAssetsManifest> staticWebAssetsManifests)
{
    public IReadOnlyDictionary<string, FileItem> Files => files;
    public LoadedProjectGraph ProjectGraph => projectGraph;
    public ProjectBuildManager BuildManager => projectGraph.BuildManager;

    public readonly FilePathExclusions ItemExclusions
        = projectGraph != null ? FilePathExclusions.Create(projectGraph.Graph) : FilePathExclusions.Empty;

    public IReadOnlyDictionary<ProjectInstanceId, StaticWebAssetsManifest> StaticWebAssetsManifests
        => staticWebAssetsManifests;

    public IReadOnlyDictionary<ProjectInstanceId, ProjectInstance> RestoredProjectInstances
        => restoredProjectInstances;

    public void WatchFileItems(FileWatcher fileWatcher)
    {
        fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true);

        fileWatcher.WatchContainingDirectories(
            StaticWebAssetsManifests.Values.SelectMany(static manifest => manifest.DiscoveryPatterns.Select(static pattern => pattern.Directory)),
            includeSubdirectories: true);
    }

    public static ImmutableDictionary<string, string> GetGlobalBuildProperties(IEnumerable<string> buildArguments, EnvironmentOptions environmentOptions)
    {
        // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md

        return BuildUtilities.ParseBuildProperties(buildArguments)
            .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)
            .SetItem(PropertyNames.DotNetWatchBuild, "true")
            .SetItem(PropertyNames.DesignTimeBuild, "true")
            .SetItem(PropertyNames.SkipCompilerExecution, "true")
            .SetItem(PropertyNames.ProvideCommandLineArgs, "true")
            // this will force CoreCompile task to execute and return command line args even if all inputs and outputs are up to date:
            .SetItem(PropertyNames.NonExistentFile, "__NonExistentSubDir__\\__NonExistentFile__");
    }

    /// <summary>
    /// Loads project graph and performs design-time build.
    /// </summary>
    public static async ValueTask<EvaluationResult?> TryCreateAsync(
        LoadedProjectGraph projectGraph,
        ILogger logger,
        GlobalOptions globalOptions,
        EnvironmentOptions environmentOptions,
        string? mainProjectTargetFramework,
        bool restore,
        CancellationToken cancellationToken)
    {
        logger.Log(MessageDescriptor.LoadingProjects);

        var projectLoadingStopwatch = Stopwatch.StartNew();
        var stopwatch = Stopwatch.StartNew();

        if (restore)
        {
            var restoreRequests = projectGraph.Graph.GraphRoots.Select(node => BuildRequest.Create(node.ProjectInstance, [TargetNames.Restore])).ToArray();

            if (await projectGraph.BuildManager.BuildAsync(
                restoreRequests,
                onFailure: failedInstance =>
                {
                    logger.LogError("Failed to restore project '{Path}'.", failedInstance.FullPath);

                    // terminate build on first failure:
                    return false;
                },
                operationName: "Restore",
                cancellationToken) is [])
            {
                return null;
            }

            logger.LogDebug("Projects restored in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0"));
        }

        stopwatch.Restart();

        // Capture the snapshot of original project instances after Restore target has been run.
        // These instances can be used to evaluate additional targets (e.g. deployment) if needed.
        var restoredProjectInstances = projectGraph.Graph.ProjectNodes.ToDictionary(
            keySelector: node => node.ProjectInstance.GetId(),
            elementSelector: node => node.ProjectInstance.DeepCopy());

        // Update the project instances of the graph with design-time build results.
        // The properties and items set by DTB will be used by the Workspace to create Roslyn representation of projects.

        var buildRequests = CreateDesignTimeBuildRequests(projectGraph.Graph, mainProjectTargetFramework, environmentOptions.SuppressHandlingStaticWebAssets).ToImmutableArray();

        var buildResults = await projectGraph.BuildManager.BuildAsync(
            buildRequests,
            onFailure: failedInstance =>
            {
                logger.LogError("Failed to build project '{Path}'.", failedInstance.FullPath);

                // terminate build on first failure:
                return false;
            },
            operationName: "DesignTimeBuild",
            cancellationToken);

        if (buildResults is [])
        {
            return null;
        }

        logger.LogDebug("Design-time build completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0"));
        logger.Log(MessageDescriptor.LoadedProjects, projectGraph.Graph.ProjectNodes.Count, projectLoadingStopwatch.Elapsed.TotalSeconds);

        ProcessBuildResults(buildResults, logger, out var fileItems, out var staticWebAssetManifests);

        BuildReporter.ReportWatchedFiles(logger, fileItems);

        return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests);
    }

    // internal for testing
    internal static IEnumerable<BuildRequest<object?>> CreateDesignTimeBuildRequests(ProjectGraph graph, string? mainProjectTargetFramework, bool suppressStaticWebAssets)
    {
        return from node in graph.ProjectNodesTopologicallySorted
               let targetFramework = node.ProjectInstance.GetTargetFramework()

               // skip outer-build projects
               where targetFramework != ""

               // skip root projects that do not match main project TFM, if specified:
               where mainProjectTargetFramework == null ||
                     targetFramework == mainProjectTargetFramework ||
                     HasParentWithTargetFramework(node)

               let targets = GetBuildTargets(node.ProjectInstance, suppressStaticWebAssets)
               where targets is not []
               select BuildRequest.Create(node.ProjectInstance, [.. targets]);

        static bool HasParentWithTargetFramework(ProjectGraphNode node)
            => node.ReferencingProjects.Any(p => p.ProjectInstance.GetTargetFramework() != "");
    }

    private static void ProcessBuildResults(
        ImmutableArray<BuildResult<object?>> buildResults,
        ILogger logger,
        out IReadOnlyDictionary<string, FileItem> fileItems,
        out IReadOnlyDictionary<ProjectInstanceId, StaticWebAssetsManifest> staticWebAssetManifests)
    {
        var fileItemsBuilder = new Dictionary<string, FileItem>();
        var staticWebAssetManifestsBuilder = new Dictionary<ProjectInstanceId, StaticWebAssetsManifest>();

        foreach (var buildResult in buildResults)
        {
            Debug.Assert(buildResult.IsSuccess);

            var projectInstance = buildResult.ProjectInstance;
            Debug.Assert(projectInstance != null);

            // command line args items should be available:
            Debug.Assert(
                !Path.GetExtension(projectInstance.FullPath).Equals(".csproj", PathUtilities.OSSpecificPathComparison) ||
                projectInstance.GetItems("CscCommandLineArgs").Any());

            var projectPath = projectInstance.FullPath;
            var projectDirectory = Path.GetDirectoryName(projectPath)!;

            if (buildResult.TargetResults.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets) &&
                projectInstance.GetIntermediateOutputDirectory() is { } outputDir &&
                StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest)
            {
                staticWebAssetManifestsBuilder.Add(projectInstance.GetId(), manifest);

                // watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated:
                foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap)
                {
                    if (!StaticWebAsset.IsCompressedAssetFile(filePath) && !StaticWebAsset.IsScopedCssBundleFile(filePath))
                    {
                        AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl);
                    }
                }
            }

            // Adds file items for scoped css files.
            // Scoped css files are bundled into a single entry per project that is represented in the static web assets manifest,
            // but we need to watch the original individual files.
            if (buildResult.TargetResults.ContainsKey(TargetNames.ResolveScopedCssInputs))
            {
                foreach (var item in projectInstance.GetItems(ItemNames.ScopedCssInput))
                {
                    AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null);
                }
            }

            // Add Watch items after other items so that we don't override properties set above.
            var items = projectInstance.GetItems(ItemNames.Compile)
                .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles))
                .Concat(projectInstance.GetItems(ItemNames.Watch));

            foreach (var item in items)
            {
                AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null);
            }

            void AddFile(string relativePath, string? staticWebAssetRelativeUrl)
            {
                var filePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath));

                if (!fileItemsBuilder.TryGetValue(filePath, out var existingFile))
                {
                    fileItemsBuilder.Add(filePath, new FileItem
                    {
                        FilePath = filePath,
                        ContainingProjectPaths = [projectPath],
                        StaticWebAssetRelativeUrl = staticWebAssetRelativeUrl,
                    });
                }
                else if (!existingFile.ContainingProjectPaths.Contains(projectPath))
                {
                    // linked files might be included to multiple projects:
                    existingFile.ContainingProjectPaths.Add(projectPath);
                }
            }
        }

        fileItems = fileItemsBuilder;
        staticWebAssetManifests = staticWebAssetManifestsBuilder;
    }

    private static string[] GetBuildTargets(ProjectInstance projectInstance, bool suppressStaticWebAssets)
    {
        var compileTarget = projectInstance.Targets.ContainsKey(TargetNames.CompileDesignTime)
            ? TargetNames.CompileDesignTime
            : projectInstance.Targets.ContainsKey(TargetNames.Compile)
            ? TargetNames.Compile
            : null;

        if (compileTarget == null)
        {
            return [];
        }

        var targets = new List<string>
        {
            compileTarget
        };

        if (!suppressStaticWebAssets)
        {
            // generates static file asset manifest
            if (projectInstance.Targets.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets))
            {
                targets.Add(TargetNames.GenerateComputedBuildStaticWebAssets);
            }

            // populates ScopedCssInput items:
            if (projectInstance.Targets.ContainsKey(TargetNames.ResolveScopedCssInputs))
            {
                targets.Add(TargetNames.ResolveScopedCssInputs);
            }
        }

        targets.AddRange(projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems));
        return [.. targets];
    }
}