Skip to content

Use ProjectGraph for DTB #49589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</ItemGroup>

<ItemGroup>
<Compile Include="..\dotnet-watch\Build\MSBuildFileSetResult.cs" />
<Compile Include="..\dotnet-watch\Watch\MSBuildFileSetResult.cs" />
</ItemGroup>

</Project>
47 changes: 47 additions & 0 deletions src/BuiltInTools/dotnet-watch/Build/BuildNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Watch;

internal static class PropertyNames
{
public const string TargetFramework = nameof(TargetFramework);
public const string TargetFrameworkIdentifier = nameof(TargetFrameworkIdentifier);
public const string TargetPath = nameof(TargetPath);
public const string EnableDefaultItems = nameof(EnableDefaultItems);
public const string TargetFrameworks = nameof(TargetFrameworks);
public const string WebAssemblyHotReloadCapabilities = nameof(WebAssemblyHotReloadCapabilities);
public const string TargetFrameworkVersion = nameof(TargetFrameworkVersion);
public const string TargetName = nameof(TargetName);
public const string IntermediateOutputPath = nameof(IntermediateOutputPath);
public const string HotReloadAutoRestart = nameof(HotReloadAutoRestart);
public const string DefaultItemExcludes = nameof(DefaultItemExcludes);
public const string CustomCollectWatchItems = nameof(CustomCollectWatchItems);
public const string UsingMicrosoftNETSdkRazor = nameof(UsingMicrosoftNETSdkRazor);
public const string DotNetWatchContentFiles = nameof(DotNetWatchContentFiles);
public const string DotNetWatchBuild = nameof(DotNetWatchBuild);
public const string DesignTimeBuild = nameof(DesignTimeBuild);
public const string SkipCompilerExecution = nameof(SkipCompilerExecution);
public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs);
}

internal static class ItemNames
{
public const string Watch = nameof(Watch);
public const string AdditionalFiles = nameof(AdditionalFiles);
public const string Compile = nameof(Compile);
public const string Content = nameof(Content);
public const string ProjectCapability = nameof(ProjectCapability);
}

internal static class MetadataNames
{
public const string Watch = nameof(Watch);
}

internal static class TargetNames
{
public const string Compile = nameof(Compile);
public const string Restore = nameof(Restore);
public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets);
}
104 changes: 104 additions & 0 deletions src/BuiltInTools/dotnet-watch/Build/BuildReporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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;
using Microsoft.Build.Framework;
using Microsoft.Build.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class BuildReporter(IReporter reporter, EnvironmentOptions environmentOptions)
{
public IReporter Reporter => reporter;
public EnvironmentOptions EnvironmentOptions => environmentOptions;

public Loggers GetLoggers(string projectPath, string operationName)
=> new(reporter, environmentOptions.GetTestBinLogPath(projectPath, operationName));

public void ReportWatchedFiles(Dictionary<string, FileItem> fileItems)
{
reporter.Verbose($"Watching {fileItems.Count} file(s) for changes");

if (environmentOptions.TestFlags.HasFlag(TestFlags.RunningAsTest))
{
foreach (var file in fileItems.Values)
{
reporter.Verbose(file.StaticWebAssetPath != null
? $"> {file.FilePath}{Path.PathSeparator}{file.StaticWebAssetPath}"
: $"> {file.FilePath}");
}
}
}

public sealed class Loggers(IReporter reporter, string? binLogPath) : IEnumerable<ILogger>, IDisposable
{
private readonly BinaryLogger? _binaryLogger = binLogPath != null
? new()
{
Verbosity = LoggerVerbosity.Diagnostic,
Parameters = "LogFile=" + binLogPath,
}
: null;

private readonly OutputLogger _outputLogger =
new(reporter)
{
Verbosity = LoggerVerbosity.Minimal
};

public void Dispose()
{
_outputLogger.Clear();
}

public IEnumerator<ILogger> GetEnumerator()
{
yield return _outputLogger;

if (_binaryLogger != null)
{
yield return _binaryLogger;
}
}

public void ReportOutput()
{
if (binLogPath != null)
{
reporter.Verbose($"Binary log: '{binLogPath}'");
}

_outputLogger.ReportOutput();
}

IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
}

private sealed class OutputLogger : ConsoleLogger
{
private readonly IReporter _reporter;
private readonly List<OutputLine> _messages = [];

public OutputLogger(IReporter reporter)
{
WriteHandler = Write;
_reporter = reporter;
}

public IReadOnlyList<OutputLine> Messages
=> _messages;

public void Clear()
=> _messages.Clear();

private void Write(string message)
=> _messages.Add(new OutputLine(message.TrimEnd('\r', '\n'), IsError: false));

public void ReportOutput()
{
_reporter.Output($"MSBuild output:");
BuildOutput.ReportBuildOutput(_reporter, Messages, success: false, projectDisplay: null);
}
}
}
141 changes: 138 additions & 3 deletions src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
// 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 Microsoft.Build.Graph;

namespace Microsoft.DotNet.Watch;

internal sealed class EvaluationResult(IReadOnlyDictionary<string, FileItem> files, ProjectGraph? projectGraph)
internal sealed class EvaluationResult(IReadOnlyDictionary<string, FileItem> files, ProjectGraph projectGraph)
{
public readonly IReadOnlyDictionary<string, FileItem> Files = files;
public readonly ProjectGraph? ProjectGraph = projectGraph;
public readonly ProjectGraph ProjectGraph = projectGraph;

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

private readonly Lazy<IReadOnlySet<string>> _lazyBuildFiles
= new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet<string>());

public static IReadOnlySet<string> CreateBuildFileSet(ProjectGraph projectGraph)
private static IReadOnlySet<string> CreateBuildFileSet(ProjectGraph projectGraph)
=> projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths)
.Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath))
.ToHashSet(PathUtilities.OSSpecificPathComparer);
Expand All @@ -29,4 +30,138 @@ public void WatchFiles(FileWatcher fileWatcher)
fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true);
fileWatcher.WatchFiles(BuildFiles);
}

/// <summary>
/// Loads project graph and performs design-time build.
/// </summary>
public static EvaluationResult? TryCreate(
string rootProjectPath,
IEnumerable<string> buildArguments,
IReporter reporter,
EnvironmentOptions environmentOptions,
bool restore,
CancellationToken cancellationToken)
{
var buildReporter = new BuildReporter(reporter, environmentOptions);

// See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md

var globalOptions = CommandLineOptions.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")
// F# targets depend on host path variable:
.SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath);

var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(
rootProjectPath,
globalOptions,
reporter,
projectGraphRequired: true,
cancellationToken);

if (projectGraph == null)
{
return null;
}

var rootNode = projectGraph.GraphRoots.Single();

if (restore)
{
using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore"))
{
if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers))
{
reporter.Error($"Failed to restore project '{rootProjectPath}'.");
loggers.ReportOutput();
return null;
}
}
}

var fileItems = new Dictionary<string, FileItem>();

foreach (var project in projectGraph.ProjectNodesTopologicallySorted)
{
// Deep copy so that we can reuse the graph for building additional targets later on.
// If we didn't copy the instance the targets might duplicate items that were already
// populated by design-time build.
var projectInstance = project.ProjectInstance.DeepCopy();

// skip outer build project nodes:
if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "")
{
continue;
}

var customCollectWatchItems = projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems);

using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild"))
{
if (!projectInstance.Build([TargetNames.Compile, .. customCollectWatchItems], loggers))
{
reporter.Error($"Failed to build project '{projectInstance.FullPath}'.");
loggers.ReportOutput();
return null;
}
}

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

// TODO: Compile and AdditionalItems should be provided by Roslyn
var items = projectInstance.GetItems(ItemNames.Compile)
.Concat(projectInstance.GetItems(ItemNames.AdditionalFiles))
.Concat(projectInstance.GetItems(ItemNames.Watch));

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

if (!environmentOptions.SuppressHandlingStaticContentFiles &&
projectInstance.GetBooleanPropertyValue(PropertyNames.UsingMicrosoftNETSdkRazor) &&
projectInstance.GetBooleanPropertyValue(PropertyNames.DotNetWatchContentFiles, defaultValue: true))
{
foreach (var item in projectInstance.GetItems(ItemNames.Content))
{
if (item.GetBooleanMetadataValue(MetadataNames.Watch, defaultValue: true))
{
var relativeUrl = item.EvaluatedInclude.Replace('\\', '/');
if (relativeUrl.StartsWith("wwwroot/"))
{
AddFile(item.EvaluatedInclude, staticWebAssetPath: relativeUrl);
}
}
}
}

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

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);

return new EvaluationResult(fileItems, projectGraph);
}
}
Loading