File: HotReload\ProjectUpdatesBuilder.cs
Web Access
Project: 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.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed partial class ProjectUpdatesBuilder
{
    public required ILogger Logger { get; init; }
    public required HotReloadService HotReloadService { get; init; }

    // inputs:
    public required ManagedCodeSnapshot ManagedCodeSnapshot { get; init; }
    public required ImmutableDictionary<string, ImmutableArray<RunningProject>> RunningProjects { get; init; }

    // outputs:
    private readonly List<ProjectId> _previousProjectUpdatesToDiscard = [];
    private readonly List<HotReloadService.Update> _managedCodeUpdates = [];
    private readonly Dictionary<RunningProject, List<StaticWebAsset>> _staticAssetUpdates = [];
    private readonly List<string> _projectsToRebuild = [];
    private readonly List<string> _projectsToRedeploy = [];
    private readonly List<RunningProject> _projectsToRestart = [];

    public IReadOnlyList<ProjectId> PreviousProjectUpdatesToDiscard => _previousProjectUpdatesToDiscard;
    public IReadOnlyList<HotReloadService.Update> ManagedCodeUpdates => _managedCodeUpdates;
    public IReadOnlyDictionary<RunningProject, List<StaticWebAsset>> StaticAssetUpdates => _staticAssetUpdates;
    public IReadOnlyList<string> ProjectsToRebuild => _projectsToRebuild;
    public IReadOnlyList<string> ProjectsToRedeploy => _projectsToRedeploy;
    public IReadOnlyList<RunningProject> ProjectsToRestart => _projectsToRestart;

    private Solution Solution
        => ManagedCodeSnapshot.Solution;

    private async ValueTask DisplayResultsAsync(
        HotReloadService.Updates updates,
        ImmutableDictionary<ProjectId, HotReloadService.RunningProjectInfo> runningProjectInfos,
        CancellationToken cancellationToken)
    {
        switch (updates.Status)
        {
            case HotReloadService.Status.ReadyToApply:
                break;

            case HotReloadService.Status.NoChangesToApply:
                Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply);
                break;

            case HotReloadService.Status.Blocked:
                Logger.Log(MessageDescriptor.UnableToApplyChanges);
                break;

            default:
                throw new InvalidOperationException();
        }

        if (!updates.ProjectsToRestart.IsEmpty)
        {
            Logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
        }

        var errorsToDisplayInApp = new List<string>();

        // Display errors first, then warnings:
        ReportCompilationDiagnostics(DiagnosticSeverity.Error);
        ReportCompilationDiagnostics(DiagnosticSeverity.Warning);
        ReportRudeEdits();

        // report or clear diagnostics in the browser UI
        await RunningProjects.ForEachValueAsync(
            (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. errorsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
            cancellationToken);

        void ReportCompilationDiagnostics(DiagnosticSeverity severity)
        {
            foreach (var diagnostic in updates.PersistentDiagnostics)
            {
                if (diagnostic.Id == "CS8002")
                {
                    // TODO: This is not a useful warning. Compiler shouldn't be reporting this on .NET/
                    // Referenced assembly '...' does not have a strong name"
                    continue;
                }

                // TODO: https://github.com/dotnet/roslyn/pull/79018
                // shouldn't be included in compilation diagnostics
                if (diagnostic.Id == "ENC0118")
                {
                    // warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted
                    continue;
                }

                if (diagnostic.DefaultSeverity != severity)
                {
                    continue;
                }

                // TODO: we don't currently have a project associated with the diagnostic
                ReportDiagnostic(diagnostic, projectDisplayPrefix: "", autoPrefix: "");
            }
        }

        void ReportRudeEdits()
        {
            // Rude edits in projects that caused restart of a project that can be restarted automatically
            // will be reported only as verbose output.
            var projectsRestartedDueToRudeEdits = updates.ProjectsToRestart
                .Where(e => IsAutoRestartEnabled(e.Key))
                .SelectMany(e => e.Value)
                .ToHashSet();

            // Project with rude edit that doesn't impact running project is only listed in ProjectsToRebuild.
            // Such projects are always auto-rebuilt whether or not there is any project to be restarted that needs a confirmation.
            var projectsRebuiltDueToRudeEdits = updates.ProjectsToRebuild
                .Where(p => !updates.ProjectsToRestart.ContainsKey(p))
                .ToHashSet();

            foreach (var (projectId, diagnostics) in updates.TransientDiagnostics)
            {
                // The diagnostic may be reported for a project that has been deleted.
                var project = Solution.GetProject(projectId);
                var projectDisplay = project != null ? $"[{GetProjectInstance(project).GetDisplayName()}] " : "";

                foreach (var diagnostic in diagnostics)
                {
                    var prefix =
                        projectsRestartedDueToRudeEdits.Contains(projectId) ? "[auto-restart] " :
                        projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " :
                        "";

                    ReportDiagnostic(diagnostic, projectDisplay, prefix);
                }
            }
        }

        bool IsAutoRestartEnabled(ProjectId id)
            => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect;

        void ReportDiagnostic(Diagnostic diagnostic, string projectDisplayPrefix, string autoPrefix)
        {
            var message = projectDisplayPrefix + autoPrefix + CSharpDiagnosticFormatter.Instance.Format(diagnostic);

            if (autoPrefix != "")
            {
                Logger.Log(MessageDescriptor.ApplyUpdate_AutoVerbose, message);
                errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage());
            }
            else
            {
                var descriptor = GetMessageDescriptor(diagnostic);
                Logger.Log(descriptor, message);

                if (descriptor.Level != LogLevel.None)
                {
                    errorsToDisplayInApp.Add(descriptor.GetMessage(message));
                }
            }
        }

        // Use the default severity of the diagnostic as it conveys impact on Hot Reload
        // (ignore warnings as errors and other severity configuration).
        static MessageDescriptor<string> GetMessageDescriptor(Diagnostic diagnostic)
        {
            if (diagnostic.Id == "ENC0118")
            {
                // Changing '<entry-point>' might not have any effect until the application is restarted.
                return MessageDescriptor.ApplyUpdate_ChangingEntryPoint;
            }

            return diagnostic.DefaultSeverity switch
            {
                DiagnosticSeverity.Error => MessageDescriptor.ApplyUpdate_Error,
                DiagnosticSeverity.Warning => MessageDescriptor.ApplyUpdate_Warning,
                _ => MessageDescriptor.ApplyUpdate_Verbose,
            };
        }
    }

    private IEnumerable<RunningProject> GetCorrespondingRunningProjects(Project project)
    {
        if (project.FilePath == null || !RunningProjects.TryGetValue(project.FilePath, out var projectsWithPath))
        {
            return [];
        }

        // msbuild workspace doesn't set TFM if the project is not multi-targeted
        var tfm = HotReloadService.GetTargetFramework(project);
        if (tfm == null)
        {
            Debug.Assert(projectsWithPath.All(p => string.Equals(p.GetTargetFramework(), projectsWithPath[0].GetTargetFramework(), StringComparison.OrdinalIgnoreCase)));
            return projectsWithPath;
        }

        return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
    }

    private ProjectInstance GetProjectInstance(Project project)
    {
        Debug.Assert(project.FilePath != null);

        if (!ManagedCodeSnapshot.ProjectInstances.TryGetValue(project.FilePath, out var instances))
        {
            throw new InvalidOperationException($"Project '{project.FilePath}' (id = '{project.Id}') not found in project graph");
        }

        // msbuild workspace doesn't set TFM if the project is not multi-targeted
        var tfm = HotReloadService.GetTargetFramework(project);
        if (tfm == null)
        {
            return instances.Single();
        }

        return instances.Single(instance => string.Equals(instance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
    }

    public async ValueTask AddManagedCodeUpdatesAsync(
        Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
        bool autoRestart,
        CancellationToken cancellationToken)
    {
        // Note that for scenarios where RunningProject has multiple managed clients (e.g. Blazor WASM hosted),
        // it is sufficient to set RestartWhenChangesHaveNoEffect for the primary running project, as all secondary managed clients
        // are associated with projects that are referenced by the the primary project and thus any updates in their cones are already
        // considered updates that affect a running project (the primary one).
        var runningProjectInfos =
           (from project in Solution.Projects
            let runningProject = GetCorrespondingRunningProjects(project).FirstOrDefault()
            where runningProject != null
            let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled()
            select (project.Id, info: new HotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject }))
            .ToImmutableDictionary(e => e.Id, e => e.info);

        var updates = await HotReloadService.GetUpdatesAsync(Solution, runningProjectInfos, cancellationToken);
        var hasPendingUpdate = updates.Status is not (HotReloadService.Status.NoChangesToApply or HotReloadService.Status.Blocked);

        var success = false;
        try
        {
            await DisplayResultsAsync(updates, runningProjectInfos, cancellationToken);

            if (!hasPendingUpdate)
            {
                // If Hot Reload is blocked (due to compilation error) we ignore the current
                // changes and await the next file change.

                // Note: CommitUpdate/DiscardUpdate is not expected to be called.
                return;
            }

            var projectsToPromptForRestart =
                (from projectId in updates.ProjectsToRestart.Keys
                 where !runningProjectInfos[projectId].RestartWhenChangesHaveNoEffect // equivalent to auto-restart
                 select Solution.GetProject(projectId)!.Name).ToList();

            if (projectsToPromptForRestart.Any() &&
                !await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken))
            {
                Logger.Log(MessageDescriptor.HotReloadSuspended);
                await Task.Delay(-1, cancellationToken);

                return;
            }

            success = true;
        }
        finally
        {
            if (hasPendingUpdate)
            {
                if (success)
                {
                    // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding.
                    HotReloadService.CommitUpdate();
                }
                else
                {
                    HotReloadService.DiscardUpdate();
                }
            }
        }

        _previousProjectUpdatesToDiscard.AddRange(updates.ProjectsToRebuild);
        _managedCodeUpdates.AddRange(updates.ProjectUpdates);
        _projectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(GetRequiredProjectFilePath));
        _projectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(GetRequiredProjectFilePath));

        // Terminate all tracked processes that need to be restarted,
        // except for the root process, which will terminate later on.
        _projectsToRestart.AddRange(
            updates.ProjectsToRestart.SelectMany(e => RunningProjects.TryGetValue(GetRequiredProjectFilePath(e.Key), out var array) ? array : []));
    }

    private string GetRequiredProjectFilePath(ProjectId projectId)
        => (Solution.GetProject(projectId) ?? throw new InvalidOperationException()).FilePath ?? throw new InvalidOperationException();
}