File: Watch\MsBuildFileSetFactory.cs
Web Access
Project: ..\..\..\src\BuiltInTools\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.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Build.Graph;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.DotNet.Watch
{
    /// <summary>
    /// Used to collect a set of files to watch.
    ///
    /// Invokes msbuild to evaluate <see cref="TargetName"/> on the root project, which traverses all project dependencies and collects
    /// items that are to be watched. The target can be customized by defining CustomCollectWatchItems target. This is currently done by Razor SDK
    /// to collect razor and cshtml files.
    /// Consider replacing with <see cref="Build.Graph.ProjectGraph"/> traversal (https://github.com/dotnet/sdk/issues/40214).
    /// </summary>
    internal class MSBuildFileSetFactory(
        string rootProjectFile,
        IEnumerable<string> buildArguments,
        ProcessRunner processRunner,
        BuildReporter buildReporter)
    {
        private const string TargetName = "GenerateWatchList";
        private const string WatchTargetsFileName = "DotNetWatch.targets";
 
        public string RootProjectFile => rootProjectFile;
        private EnvironmentOptions EnvironmentOptions => buildReporter.EnvironmentOptions;
        private ILogger Logger => buildReporter.Logger;
 
        internal sealed class EvaluationResult(IReadOnlyDictionary<string, FileItem> files, ProjectGraph? projectGraph)
        {
            public readonly IReadOnlyDictionary<string, FileItem> Files = files;
            public readonly ProjectGraph? ProjectGraph = projectGraph;
        }
 
        // Virtual for testing.
        public virtual async ValueTask<EvaluationResult?> TryCreateAsync(bool? requireProjectGraph, CancellationToken cancellationToken)
        {
            var watchList = Path.GetTempFileName();
            try
            {
                var projectDir = Path.GetDirectoryName(rootProjectFile);
                var arguments = GetMSBuildArguments(watchList);
                var capturedOutput = new List<OutputLine>();
 
                var processSpec = new ProcessSpec
                {
                    Executable = EnvironmentOptions.MuxerPath,
                    WorkingDirectory = projectDir,
                    IsUserApplication = false,
                    Arguments = arguments,
                    OnOutput = line =>
                    {
                        lock (capturedOutput)
                        {
                            capturedOutput.Add(line);
                        }
                    }
                };
 
                Logger.LogDebug("Running MSBuild target '{TargetName}' on '{Path}'", TargetName, rootProjectFile);
 
                var exitCode = await processRunner.RunAsync(processSpec, Logger, launchResult: null, cancellationToken);
 
                var success = exitCode == 0 && File.Exists(watchList);
 
                if (!success)
                {
                    Logger.LogError("Error(s) finding watch items project file '{FileName}'.", Path.GetFileName(rootProjectFile));
                    Logger.LogInformation("MSBuild output from target '{TargetName}':", TargetName);
                }
 
                BuildOutput.ReportBuildOutput(Logger, capturedOutput, success, projectDisplay: null);
                if (!success)
                {
                    return null;
                }
 
                using var watchFile = File.OpenRead(watchList);
                var result = await JsonSerializer.DeserializeAsync<MSBuildFileSetResult>(watchFile, cancellationToken: cancellationToken);
                Debug.Assert(result != null);
 
                var fileItems = new Dictionary<string, FileItem>();
                foreach (var (projectPath, projectItems) in result.Projects)
                {
                    foreach (var filePath in projectItems.Files)
                    {
                        AddFile(filePath, staticWebAssetPath: null);
                    }
 
                    foreach (var staticFile in projectItems.StaticFiles)
                    {
                        AddFile(staticFile.FilePath, staticFile.StaticWebAssetPath);
                    }
 
                    void AddFile(string filePath, string? staticWebAssetPath)
                    {
                        if (!fileItems.TryGetValue(filePath, out var existingFile))
                        {
                            fileItems.Add(filePath, new FileItem
                            {
                                FilePath = filePath,
                                ContainingProjectPaths = [projectPath],
                                StaticWebAssetPath = staticWebAssetPath,
                            });
                        }
                        else if (!existingFile.ContainingProjectPaths.Contains(projectPath))
                        {
                            // linked files might be included to multiple projects:
                            existingFile.ContainingProjectPaths.Add(projectPath);
                        }
                    }
                }
 
                buildReporter.ReportWatchedFiles(fileItems);
#if DEBUG
                Debug.Assert(fileItems.Values.All(f => Path.IsPathRooted(f.FilePath)), "All files should be rooted paths");
#endif
 
                // Load the project graph after the project has been restored:
                ProjectGraph? projectGraph = null;
                if (requireProjectGraph != null)
                {
                    var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments)
                        .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value);
 
                    projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(rootProjectFile, globalOptions, Logger, requireProjectGraph.Value, cancellationToken);
                    if (projectGraph == null && requireProjectGraph == true)
                    {
                        return null;
                    }
                }
 
                return new EvaluationResult(fileItems, projectGraph);
            }
            finally
            {
                File.Delete(watchList);
            }
        }
 
        private IReadOnlyList<string> GetMSBuildArguments(string watchListFilePath)
        {
            var watchTargetsFile = FindTargetsFile();
 
            var arguments = new List<string>
            {
                "msbuild",
                "/restore",
                "/nologo",
                "/v:m",
                rootProjectFile,
                "/t:" + TargetName
            };
 
            arguments.AddRange(buildArguments);
 
            // Set dotnet-watch reserved properties after the user specified propeties,
            // so that the former take precedence.
 
            if (EnvironmentOptions.SuppressHandlingStaticContentFiles)
            {
                arguments.Add("/p:DotNetWatchContentFiles=false");
            }
 
            arguments.Add("/p:_DotNetWatchListFile=" + watchListFilePath);
            arguments.Add("/p:DotNetWatchBuild=true"); // extensibility point for users
            arguments.Add("/p:DesignTimeBuild=true"); // don't do expensive things
            arguments.Add("/p:CustomAfterMicrosoftCommonTargets=" + watchTargetsFile);
            arguments.Add("/p:CustomAfterMicrosoftCommonCrossTargetingTargets=" + watchTargetsFile);
 
            return arguments;
        }
 
        private static string FindTargetsFile()
        {
            var assemblyDir = Path.GetDirectoryName(typeof(MSBuildFileSetFactory).Assembly.Location);
            Debug.Assert(assemblyDir != null);
 
            var searchPaths = new[]
            {
                Path.Combine(AppContext.BaseDirectory, "assets"),
                Path.Combine(assemblyDir, "assets"),
                AppContext.BaseDirectory,
                assemblyDir,
            };
 
            var targetPath = searchPaths.Select(p => Path.Combine(p, WatchTargetsFileName)).FirstOrDefault(File.Exists);
            return targetPath ?? throw new FileNotFoundException("Fatal error: could not find DotNetWatch.targets");
        }
    }
}