File: Watch\BuildEvaluator.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\dotnet-watch\dotnet-watch.csproj (dotnet-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.Diagnostics;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal class BuildEvaluator
{
    // File types that require an MSBuild re-evaluation
    private static readonly string[] s_msBuildFileExtensions = new[]
    {
        ".csproj", ".props", ".targets", ".fsproj", ".vbproj", ".vcxproj",
    };

    private static readonly int[] s_msBuildFileExtensionHashes = s_msBuildFileExtensions
        .Select(e => e.GetHashCode(StringComparison.OrdinalIgnoreCase))
        .ToArray();

    private readonly MSBuildFileSetFactory _fileSetFactory;
    private readonly DotNetWatchContext _context;

    private List<(string fileName, DateTime lastWriteTimeUtc)>? _msbuildFileTimestamps;

    // result of the last evaluation, or null if no evaluation has been performed yet.
    private MSBuildFileSetFactory.EvaluationResult? _evaluationResult;

    public bool RequiresRevaluation { get; set; }

    public BuildEvaluator(DotNetWatchContext context)
    {
        Debug.Assert(context.MainProjectOptions != null);

        _context = context;
        _fileSetFactory = CreateMSBuildFileSetFactory();
    }

    private ProjectOptions MainProjectOptions
        => _context.MainProjectOptions ?? throw new InvalidOperationException();

    protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory()
    {
        // file-based programs only supported in Hot Reload mode:
        Debug.Assert(MainProjectOptions.Representation.PhysicalPath != null);

        return new(
            MainProjectOptions.Representation.PhysicalPath,
            MainProjectOptions.TargetFramework,
            _context.BuildArguments,
            _context.ProcessRunner,
            _context.BuildLogger,
            _context.Options,
            _context.EnvironmentOptions);
    }

    public IReadOnlyList<string> GetProcessArguments(int iteration)
    {
        var noRestore = false;
        if (!_context.EnvironmentOptions.SuppressMSBuildIncrementalism &&
            iteration > 0 &&
            MainProjectOptions.IsCodeExecutionCommand)
        {
            if (RequiresRevaluation)
            {
                _context.Logger.LogDebug("Cannot use --no-restore since msbuild project files have changed.");
            }
            else
            {
                _context.Logger.LogDebug("Modifying command to use --no-restore");
                noRestore = true;
            }
        }

        var arguments = new List<string>()
        {
            MainProjectOptions.Command
        };

        if (noRestore)
        {
            arguments.Add("--no-restore");
        }

        if (MainProjectOptions.TargetFramework != null)
        {
            arguments.Add("--framework");
            arguments.Add(MainProjectOptions.TargetFramework);
        }

        if (MainProjectOptions.Device != null)
        {
            arguments.Add("--device");
            arguments.Add(MainProjectOptions.Device);
        }

        arguments.AddRange(MainProjectOptions.CommandArguments);

        return arguments;
    }

    public async ValueTask<MSBuildFileSetFactory.EvaluationResult> EvaluateAsync(ChangedFile? changedFile, CancellationToken cancellationToken)
    {
        if (_context.EnvironmentOptions.SuppressMSBuildIncrementalism)
        {
            RequiresRevaluation = true;
            return _evaluationResult = await CreateEvaluationResult(cancellationToken);
        }

        if (_evaluationResult == null || RequiresMSBuildRevaluation(changedFile?.Item))
        {
            RequiresRevaluation = true;
        }

        if (RequiresRevaluation)
        {
            _context.Logger.LogDebug("Evaluating dotnet-watch file set.");

            var result = await CreateEvaluationResult(cancellationToken);
            _msbuildFileTimestamps = GetMSBuildFileTimeStamps(result);
            return _evaluationResult = result;
        }

        Debug.Assert(_evaluationResult != null);
        return _evaluationResult;
    }

    private async ValueTask<MSBuildFileSetFactory.EvaluationResult> CreateEvaluationResult(CancellationToken cancellationToken)
    {
        while (true)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var result = await _fileSetFactory.TryCreateAsync(requireProjectGraph: true, cancellationToken);
            if (result != null)
            {
                return result;
            }

            await FileWatcher.WaitForFileChangeAsync(
                [_fileSetFactory.RootProjectFile],
                _context.Logger,
                _context.EnvironmentOptions,
                startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError),
                cancellationToken);
        }
    }

    private bool RequiresMSBuildRevaluation(FileItem? changedFile)
    {
        Debug.Assert(_msbuildFileTimestamps != null);

        if (changedFile != null && IsMsBuildFileExtension(changedFile.Value.FilePath))
        {
            return true;
        }

        // The filewatcher may miss changes to files. For msbuild files, we can verify that they haven't been modified
        // since the previous iteration.
        // We do not have a way to identify renames or new additions that the file watcher did not pick up,
        // without performing an evaluation. We will start off by keeping it simple and comparing the timestamps
        // of known MSBuild files from previous run. This should cover the vast majority of cases.

        foreach (var (file, lastWriteTimeUtc) in _msbuildFileTimestamps)
        {
            if (GetLastWriteTimeUtcSafely(file) != lastWriteTimeUtc)
            {
                _context.Logger.LogDebug("Re-evaluation needed due to changes in '{Path}'.", file);

                return true;
            }
        }

        return false;
    }

    private List<(string fileName, DateTime lastModifiedUtc)> GetMSBuildFileTimeStamps(MSBuildFileSetFactory.EvaluationResult result)
    {
        var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>();
        foreach (var (filePath, _) in result.Files)
        {
            if (!string.IsNullOrEmpty(filePath) && IsMsBuildFileExtension(filePath))
            {
                msbuildFiles.Add((filePath, GetLastWriteTimeUtcSafely(filePath)));
            }
        }

        return msbuildFiles;
    }

    protected virtual DateTime GetLastWriteTimeUtcSafely(string file)
    {
        try
        {
            return File.GetLastWriteTimeUtc(file);
        }
        catch
        {
            return DateTime.UtcNow;
        }
    }

    private static bool IsMsBuildFileExtension(string fileName)
    {
        var extension = Path.GetExtension(fileName.AsSpan());
        var hashCode = string.GetHashCode(extension, StringComparison.OrdinalIgnoreCase);
        for (var i = 0; i < s_msBuildFileExtensionHashes.Length; i++)
        {
            if (s_msBuildFileExtensionHashes[i] == hashCode && extension.Equals(s_msBuildFileExtensions[i], StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }

        return false;
    }
}