diff --git a/Directory.Packages.props b/Directory.Packages.props index a44ed722182f..3e9b3bd272de 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index c85ec7c9ebaa..75069972e216 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -124,6 +124,10 @@ https://github.com/dotnet/dotnet 9477b510bb25fc69515d2ab188af3b72799929ac + + https://github.com/dotnet/roslyn + 46a48b8c1dfce7c35da115308bedd6a5954fd78a + https://github.com/dotnet/dotnet 9477b510bb25fc69515d2ab188af3b72799929ac diff --git a/sdk.slnx b/sdk.slnx index 1f9a8f45530d..250dcce38c1a 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -23,6 +23,7 @@ + diff --git a/src/BuiltInTools/BuiltInTools.sln b/src/BuiltInTools/BuiltInTools.sln new file mode 100644 index 000000000000..f3bcfa7210f8 --- /dev/null +++ b/src/BuiltInTools/BuiltInTools.sln @@ -0,0 +1,146 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspireService", "AspireService", "{364DE115-7FAE-CA31-C478-9A2E79CB519F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BrowserRefresh", "BrowserRefresh", "{AC3BAF4A-B943-5F19-3116-A3FD7C885CF5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-format", "dotnet-format\dotnet-format.csproj", "{6C5FBD7D-2E3D-D482-5415-893AE4661749}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-watch", "dotnet-watch\dotnet-watch.csproj", "{31818B17-178A-0598-6C6E-E15CD63E1274}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DotNetDeltaApplier", "DotNetDeltaApplier", "{1E2BF55A-FFB6-7358-58F1-01FF3B44BF81}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWatchTasks", "DotNetWatchTasks\DotNetWatchTasks.csproj", "{4855C790-EFE7-94ED-C369-0A02387F88B4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadAgent", "HotReloadAgent", "{5CDE4159-9BB7-F002-81EE-FE1EB501625E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadAgent.Data", "HotReloadAgent.Data", "{2E82323A-31E4-7672-52A7-A568AFE575B9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadAgent.Host", "HotReloadAgent.Host", "{0305277F-4D82-FB13-2499-FC48C606CE40}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadAgent.PipeRpc", "HotReloadAgent.PipeRpc", "{DE1641C4-FDB5-AA6D-6CB4-DF3846EF52B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadAgent.WebAssembly.Browser", "HotReloadAgent.WebAssembly.Browser", "{4ECEBE05-09E3-57E8-4D2B-FE584872CA1A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HotReloadClient", "HotReloadClient", "{D2C117CF-0177-4E2D-50C8-838AB4048BBB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Watch", "Watch", "{7DA52029-B853-C26E-9887-EED38DF1A1F0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Watch.Aspire", "Watch.Aspire", "{4F97FBBD-9A86-61E0-BF34-CA189133CA2A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web.Middleware", "Web.Middleware", "{6416C8BE-C082-13BD-174D-AC0F1C4E3A1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebTools.AspireService.Package", "AspireService\Microsoft.WebTools.AspireService.Package.csproj", "{DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Watch.BrowserRefresh", "BrowserRefresh\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj", "{9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Extensions.DotNetDeltaApplier", "DotNetDeltaApplier\Microsoft.Extensions.DotNetDeltaApplier.csproj", "{6BF335B3-9AE2-45DA-BF06-984D1FC76D12}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Agent.Package", "HotReloadAgent\Microsoft.DotNet.HotReload.Agent.Package.csproj", "{5EA9373C-1BAB-FA35-C694-5D55DF99EED4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Agent.Data.Package", "HotReloadAgent.Data\Microsoft.DotNet.HotReload.Agent.Data.Package.csproj", "{EE06EDB0-986D-753A-FE69-F65321BEED81}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Agent.Host.Package", "HotReloadAgent.Host\Microsoft.DotNet.HotReload.Agent.Host.Package.csproj", "{806A277E-2C0C-22FB-921A-78886FFDDAA7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Agent.PipeRpc.Package", "HotReloadAgent.PipeRpc\Microsoft.DotNet.HotReload.Agent.PipeRpc.Package.csproj", "{AB9EFD18-6E38-A909-42D0-CFFAB80A8497}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.WebAssembly.Browser", "HotReloadAgent.WebAssembly.Browser\Microsoft.DotNet.HotReload.WebAssembly.Browser.csproj", "{4399F1FA-7146-7FC6-527B-80445C258185}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Client.Package", "HotReloadClient\Microsoft.DotNet.HotReload.Client.Package.csproj", "{C736CE49-22AA-C13A-A009-110F2BB42B3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Watch", "Watch\Microsoft.DotNet.HotReload.Watch.csproj", "{A66CCBDA-9D70-725D-1E21-BE53E0CB767E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Watch.Aspire", "Watch.Aspire\Microsoft.DotNet.HotReload.Watch.Aspire.csproj", "{A3C70BA5-7E79-E064-E5BB-07C0A3FB830F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.HotReload.Web.Middleware.Package", "Web.Middleware\Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj", "{1344423B-CFA7-FC1A-935D-4B9D2BE8C512}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6C5FBD7D-2E3D-D482-5415-893AE4661749}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C5FBD7D-2E3D-D482-5415-893AE4661749}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C5FBD7D-2E3D-D482-5415-893AE4661749}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C5FBD7D-2E3D-D482-5415-893AE4661749}.Release|Any CPU.Build.0 = Release|Any CPU + {31818B17-178A-0598-6C6E-E15CD63E1274}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31818B17-178A-0598-6C6E-E15CD63E1274}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31818B17-178A-0598-6C6E-E15CD63E1274}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31818B17-178A-0598-6C6E-E15CD63E1274}.Release|Any CPU.Build.0 = Release|Any CPU + {4855C790-EFE7-94ED-C369-0A02387F88B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4855C790-EFE7-94ED-C369-0A02387F88B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4855C790-EFE7-94ED-C369-0A02387F88B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4855C790-EFE7-94ED-C369-0A02387F88B4}.Release|Any CPU.Build.0 = Release|Any CPU + {DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48}.Release|Any CPU.Build.0 = Release|Any CPU + {9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F}.Release|Any CPU.Build.0 = Release|Any CPU + {6BF335B3-9AE2-45DA-BF06-984D1FC76D12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BF335B3-9AE2-45DA-BF06-984D1FC76D12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BF335B3-9AE2-45DA-BF06-984D1FC76D12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BF335B3-9AE2-45DA-BF06-984D1FC76D12}.Release|Any CPU.Build.0 = Release|Any CPU + {5EA9373C-1BAB-FA35-C694-5D55DF99EED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EA9373C-1BAB-FA35-C694-5D55DF99EED4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EA9373C-1BAB-FA35-C694-5D55DF99EED4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EA9373C-1BAB-FA35-C694-5D55DF99EED4}.Release|Any CPU.Build.0 = Release|Any CPU + {EE06EDB0-986D-753A-FE69-F65321BEED81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE06EDB0-986D-753A-FE69-F65321BEED81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE06EDB0-986D-753A-FE69-F65321BEED81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE06EDB0-986D-753A-FE69-F65321BEED81}.Release|Any CPU.Build.0 = Release|Any CPU + {806A277E-2C0C-22FB-921A-78886FFDDAA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {806A277E-2C0C-22FB-921A-78886FFDDAA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {806A277E-2C0C-22FB-921A-78886FFDDAA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {806A277E-2C0C-22FB-921A-78886FFDDAA7}.Release|Any CPU.Build.0 = Release|Any CPU + {AB9EFD18-6E38-A909-42D0-CFFAB80A8497}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB9EFD18-6E38-A909-42D0-CFFAB80A8497}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB9EFD18-6E38-A909-42D0-CFFAB80A8497}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB9EFD18-6E38-A909-42D0-CFFAB80A8497}.Release|Any CPU.Build.0 = Release|Any CPU + {4399F1FA-7146-7FC6-527B-80445C258185}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4399F1FA-7146-7FC6-527B-80445C258185}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4399F1FA-7146-7FC6-527B-80445C258185}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4399F1FA-7146-7FC6-527B-80445C258185}.Release|Any CPU.Build.0 = Release|Any CPU + {C736CE49-22AA-C13A-A009-110F2BB42B3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C736CE49-22AA-C13A-A009-110F2BB42B3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C736CE49-22AA-C13A-A009-110F2BB42B3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C736CE49-22AA-C13A-A009-110F2BB42B3A}.Release|Any CPU.Build.0 = Release|Any CPU + {A66CCBDA-9D70-725D-1E21-BE53E0CB767E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A66CCBDA-9D70-725D-1E21-BE53E0CB767E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A66CCBDA-9D70-725D-1E21-BE53E0CB767E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A66CCBDA-9D70-725D-1E21-BE53E0CB767E}.Release|Any CPU.Build.0 = Release|Any CPU + {A3C70BA5-7E79-E064-E5BB-07C0A3FB830F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3C70BA5-7E79-E064-E5BB-07C0A3FB830F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3C70BA5-7E79-E064-E5BB-07C0A3FB830F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3C70BA5-7E79-E064-E5BB-07C0A3FB830F}.Release|Any CPU.Build.0 = Release|Any CPU + {1344423B-CFA7-FC1A-935D-4B9D2BE8C512}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1344423B-CFA7-FC1A-935D-4B9D2BE8C512}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1344423B-CFA7-FC1A-935D-4B9D2BE8C512}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1344423B-CFA7-FC1A-935D-4B9D2BE8C512}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DF68EDC4-66E0-CD0A-C3AE-DA75F6E6FB48} = {364DE115-7FAE-CA31-C478-9A2E79CB519F} + {9AA89CA7-A32B-7B0F-3F6E-D8BFF3A7394F} = {AC3BAF4A-B943-5F19-3116-A3FD7C885CF5} + {6BF335B3-9AE2-45DA-BF06-984D1FC76D12} = {1E2BF55A-FFB6-7358-58F1-01FF3B44BF81} + {5EA9373C-1BAB-FA35-C694-5D55DF99EED4} = {5CDE4159-9BB7-F002-81EE-FE1EB501625E} + {EE06EDB0-986D-753A-FE69-F65321BEED81} = {2E82323A-31E4-7672-52A7-A568AFE575B9} + {806A277E-2C0C-22FB-921A-78886FFDDAA7} = {0305277F-4D82-FB13-2499-FC48C606CE40} + {AB9EFD18-6E38-A909-42D0-CFFAB80A8497} = {DE1641C4-FDB5-AA6D-6CB4-DF3846EF52B5} + {4399F1FA-7146-7FC6-527B-80445C258185} = {4ECEBE05-09E3-57E8-4D2B-FE584872CA1A} + {C736CE49-22AA-C13A-A009-110F2BB42B3A} = {D2C117CF-0177-4E2D-50C8-838AB4048BBB} + {A66CCBDA-9D70-725D-1E21-BE53E0CB767E} = {7DA52029-B853-C26E-9887-EED38DF1A1F0} + {A3C70BA5-7E79-E064-E5BB-07C0A3FB830F} = {4F97FBBD-9A86-61E0-BF34-CA189133CA2A} + {1344423B-CFA7-FC1A-935D-4B9D2BE8C512} = {6416C8BE-C082-13BD-174D-AC0F1C4E3A1F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {347C85E1-C073-4D1E-B70E-7341B4888A27} + EndGlobalSection +EndGlobal diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 1642f63f8b82..7792d925bbac 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -20,7 +20,7 @@ namespace Microsoft.DotNet.HotReload { - internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool enableStaticAssetUpdates) + internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool handlesStaticAssetUpdates) : HotReloadClient(logger, agentLogger) { private readonly string _namedPipeName = Guid.NewGuid().ToString("N"); @@ -225,7 +225,7 @@ static ImmutableArray ToRuntimeUpdates(IEnumerable> ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken processExitedCancellationToken, CancellationToken cancellationToken) { - if (!enableStaticAssetUpdates) + if (!handlesStaticAssetUpdates) { // The client has no concept of static assets. return Task.FromResult(true); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index 1b02eac9de48..1400a43c7e09 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -16,13 +17,25 @@ namespace Microsoft.DotNet.HotReload; -internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients, AbstractBrowserRefreshServer? browserRefreshServer) : IDisposable +/// +/// Facilitates Hot Reload updates across multiple clients/processes. +/// +/// +/// Clients that handle managed updates and static asset updates if is false. +/// +/// +/// Browser refresh server used to communicate managed code update status and errors to the browser, +/// and to apply static asset updates if is true. +/// +/// +/// True to use to apply static asset updates (if available). +/// False to use the to apply static asset updates. +/// +internal sealed class HotReloadClients( + ImmutableArray<(HotReloadClient client, string name)> clients, + AbstractBrowserRefreshServer? browserRefreshServer, + bool useRefreshServerToApplyStaticAssets) : IDisposable { - public HotReloadClients(HotReloadClient client, AbstractBrowserRefreshServer? browserRefreshServer) - : this([(client, "")], browserRefreshServer) - { - } - /// /// Disposes all clients. Can occur unexpectedly whenever the process exits. /// @@ -34,6 +47,16 @@ public void Dispose() } } + /// + /// True if Hot Reload is implemented via managed agents. + /// The update itself might not be managed code update, it may be a static asset update implemented via a managed agent. + /// + public bool IsManagedAgentSupported + => !clients.IsEmpty; + + public bool UseRefreshServerToApplyStaticAssets + => useRefreshServerToApplyStaticAssets; + public AbstractBrowserRefreshServer? BrowserRefreshServer => browserRefreshServer; @@ -59,18 +82,6 @@ public event Action OnRuntimeRudeEdit } } - /// - /// All clients share the same loggers. - /// - public ILogger ClientLogger - => clients.First().client.Logger; - - /// - /// All clients share the same loggers. - /// - public ILogger AgentLogger - => clients.First().client.AgentLogger; - internal void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { foreach (var (client, _) in clients) @@ -99,6 +110,12 @@ internal async ValueTask WaitForConnectionEstablishedAsync(CancellationToken can /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) { + if (!IsManagedAgentSupported) + { + // empty capabilities will cause rude edit ENC0097: NotSupportedByRuntime. + return []; + } + if (clients is [var (singleClient, _)]) { return await singleClient.GetUpdateCapabilitiesAsync(cancellationToken); @@ -114,6 +131,9 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + // Apply to all processes. // The module the change is for does not need to be loaded to any of the processes, yet we still consider it successful if the application does not fail. // In each process we store the deltas for application when/if the module is loaded to the process later. @@ -137,6 +157,9 @@ async Task CompleteApplyOperationAsync() /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + if (clients is [var (singleClient, _)]) { await singleClient.InitialUpdatesAppliedAsync(cancellationToken); @@ -150,23 +173,26 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyStaticAssetUpdatesAsync(IEnumerable assets, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { - if (browserRefreshServer != null) + if (useRefreshServerToApplyStaticAssets) { + Debug.Assert(browserRefreshServer != null); return browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.RelativeUrl), applyOperationCancellationToken).AsTask(); } + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + var updates = new List(); foreach (var asset in assets) { try { - ClientLogger.LogDebug("Loading asset '{Url}' from '{Path}'.", asset.RelativeUrl, asset.FilePath); updates.Add(await HotReloadStaticAssetUpdate.CreateAsync(asset, cancellationToken)); } catch (Exception e) when (e is not OperationCanceledException) { - ClientLogger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); + clients.First().client.Logger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); continue; } } @@ -177,6 +203,10 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken) { + // shouldn't be called if there are no clients + Debug.Assert(IsManagedAgentSupported); + Debug.Assert(!useRefreshServerToApplyStaticAssets); + var applyTasks = await Task.WhenAll(clients.Select(c => c.client.ApplyStaticAssetUpdatesAsync(updates, applyOperationCancellationToken, cancellationToken))); return Task.WhenAll(applyTasks); diff --git a/src/BuiltInTools/HotReloadClient/StaticAsset.cs b/src/BuiltInTools/HotReloadClient/StaticAsset.cs new file mode 100644 index 000000000000..74cf7d1e096d --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/StaticAsset.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) +{ + public string FilePath => filePath; + public string RelativeUrl => relativeUrl; + public string AssemblyName => assemblyName; + public bool IsApplicationProject => isApplicationProject; +} diff --git a/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs index 22a1c4d5e0a5..6f9ad71f4bab 100644 --- a/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs +++ b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -90,6 +90,11 @@ public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out { stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); } + catch (FileNotFoundException) + { + logger.LogDebug("File '{FilePath}' does not exist.", path); + return null; + } catch (Exception e) { logger.LogError("Failed to read '{FilePath}': {Message}", path, e.Message); diff --git a/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs b/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs index 72fadfe6f024..dd56ed47d0c3 100644 --- a/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs +++ b/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs @@ -24,15 +24,12 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti commandArguments.AddRange(options.ApplicationArguments); - var rootProjectOptions = new ProjectOptions() + var mainProjectOptions = new ProjectOptions() { - IsRootProject = true, - ProjectPath = options.ProjectPath, + IsMainProject = true, + Representation = options.Project, WorkingDirectory = workingDirectory, - TargetFramework = null, - BuildArguments = [], - NoLaunchProfile = options.NoLaunchProfile, - LaunchProfileName = null, + LaunchProfileName = options.NoLaunchProfile ? default : null, Command = "run", CommandArguments = [.. commandArguments], LaunchEnvironmentVariables = [], @@ -59,7 +56,10 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti ProcessRunner = processRunner, Options = globalOptions, EnvironmentOptions = environmentOptions, - RootProjectOptions = rootProjectOptions, + MainProjectOptions = mainProjectOptions, + RootProjects = [mainProjectOptions.Representation], + BuildArguments = [], + TargetFramework = null, BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), BrowserLauncher = new BrowserLauncher(logger, reporter, environmentOptions), }; diff --git a/src/BuiltInTools/Watch.Aspire/DotNetWatchOptions.cs b/src/BuiltInTools/Watch.Aspire/DotNetWatchOptions.cs index 1d8f526c1fff..497078f9322d 100644 --- a/src/BuiltInTools/Watch.Aspire/DotNetWatchOptions.cs +++ b/src/BuiltInTools/Watch.Aspire/DotNetWatchOptions.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.CommandLine; +using System.CommandLine.Parsing; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; @@ -16,7 +17,7 @@ internal sealed class DotNetWatchOptions /// public required string SdkDirectory { get; init; } - public required string ProjectPath { get; init; } + public required ProjectRepresentation Project { get; init; } public required ImmutableArray ApplicationArguments { get; init; } public LogLevel LogLevel { get; init; } public bool NoLaunchProfile { get; init; } @@ -24,7 +25,8 @@ internal sealed class DotNetWatchOptions public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOptions? options) { var sdkOption = new Option("--sdk") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; - var projectOption = new Option("--project") { Arity = ArgumentArity.ExactlyOne, Required = true, AllowMultipleArgumentsPerToken = false }; + var projectOption = new Option("--project") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; + var fileOption = new Option("--file") { Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; var quietOption = new Option("--quiet") { Arity = ArgumentArity.Zero }; var verboseOption = new Option("--verbose") { Arity = ArgumentArity.Zero }; var noLaunchProfileOption = new Option("--no-launch-profile") { Arity = ArgumentArity.Zero }; @@ -32,10 +34,19 @@ public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOp verboseOption.Validators.Add(v => { - if (v.GetValue(quietOption) && v.GetValue(verboseOption)) + if (HasOption(v, quietOption) && HasOption(v, verboseOption)) { v.AddError("Cannot specify both '--quiet' and '--verbose' options."); } + + if (HasOption(v, projectOption) && HasOption(v, fileOption)) + { + v.AddError("Cannot specify both '--file' and '--project' options."); + } + else if (!HasOption(v, projectOption) && !HasOption(v, fileOption)) + { + v.AddError("Must specify either '--file' or '--project' option."); + } }); var rootCommand = new RootCommand() @@ -45,6 +56,7 @@ public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOp { sdkOption, projectOption, + fileOption, quietOption, verboseOption, noLaunchProfileOption @@ -70,7 +82,7 @@ public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOp options = new DotNetWatchOptions() { SdkDirectory = parseResult.GetRequiredValue(sdkOption), - ProjectPath = parseResult.GetRequiredValue(projectOption), + Project = new ProjectRepresentation(projectPath: parseResult.GetValue(projectOption), entryPointFilePath: parseResult.GetValue(fileOption)), LogLevel = parseResult.GetValue(quietOption) ? LogLevel.Warning : parseResult.GetValue(verboseOption) ? LogLevel.Debug : LogLevel.Information, ApplicationArguments = [.. parseResult.GetValue(applicationArguments) ?? []], NoLaunchProfile = parseResult.GetValue(noLaunchProfileOption), @@ -78,4 +90,7 @@ public static bool TryParse(string[] args, [NotNullWhen(true)] out DotNetWatchOp return true; } + + private static bool HasOption(SymbolResult symbolResult, Option option) + => symbolResult.GetResult(option) is OptionResult or && !or.Implicit; } diff --git a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs index ea3480f979f6..ef0c112b98fb 100644 --- a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyAppModel.cs @@ -17,11 +17,11 @@ internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, Proj { public override ProjectGraphNode LaunchingProject => clientProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), browserRefreshServer); + return [(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "")]; } } diff --git a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs index 12108762305b..940a9d0668ec 100644 --- a/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -19,17 +19,16 @@ internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh => true; + public override bool ManagedHotReloadRequiresBrowserRefresh => true; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new( - [ - (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), - (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: false), "host") - ], - browserRefreshServer); + return + [ + (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), + (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: false), "host") + ]; } } diff --git a/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs b/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs index 300236d7250a..ca04de711594 100644 --- a/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/DefaultAppModel.cs @@ -12,6 +12,11 @@ namespace Microsoft.DotNet.Watch; /// internal sealed class DefaultAppModel(ProjectGraphNode project) : HotReloadAppModel { - public override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) - => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), enableStaticAssetUpdates: true), browserRefreshServer: null)); + public override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + => new(new HotReloadClients( + clients: IsManagedAgentSupported(project, clientLogger) + ? [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), handlesStaticAssetUpdates: true), "")] + : [], + browserRefreshServer: null, + useRefreshServerToApplyStaticAssets: false)); } diff --git a/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs index 7a205a8d1fce..a6668d57714a 100644 --- a/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/HotReloadAppModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Watch; internal abstract partial class HotReloadAppModel() { - public abstract ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); + public abstract ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); protected static string GetInjectedAssemblyPath(string targetFramework, string assemblyName) => Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", targetFramework, assemblyName + ".dll"); @@ -45,4 +45,42 @@ public static HotReloadAppModel InferFromProject(DotNetWatchContext context, Pro context.Logger.Log(MessageDescriptor.ApplicationKind_Default); return new DefaultAppModel(projectNode); } + + /// + /// True if a managed code agent can be injected into the target process. + /// The agent is injected either via dotnet startup hook, or via web server middleware for WASM clients. + /// + internal static bool IsManagedAgentSupported(ProjectGraphNode project, ILogger logger) + { + if (!project.IsNetCoreApp(Versions.Version6_0)) + { + LogWarning("target framework is older than 6.0"); + return false; + } + + // If property is not specified startup hook is enabled: + // https://github.com/dotnet/runtime/blob/4b0b7238ba021b610d3963313b4471517108d2bc/src/libraries/System.Private.CoreLib/src/System/StartupHookProvider.cs#L22 + // Startup hooks are not used for WASM projects. + // + // TODO: Remove once implemented: https://github.com/dotnet/runtime/issues/123778 + if (!project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.StartupHookSupport, defaultValue: true) && + !project.GetCapabilities().Contains(ProjectCapability.WebAssembly)) + { + // Report which property is causing lack of support for startup hooks: + var (propertyName, propertyValue) = + project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishAot) + ? (PropertyNames.PublishAot, true) + : project.ProjectInstance.GetBooleanPropertyValue(PropertyNames.PublishTrimmed) + ? (PropertyNames.PublishTrimmed, true) + : (PropertyNames.StartupHookSupport, false); + + LogWarning(string.Format("'{0}' property is '{1}'", propertyName, propertyValue)); + return false; + } + + return true; + + void LogWarning(string reason) + => logger.Log(MessageDescriptor.ProjectDoesNotSupportHotReload, reason); + } } diff --git a/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs index 0f1fbb74d5d8..2460d27a79e7 100644 --- a/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/WebApplicationAppModel.cs @@ -16,25 +16,24 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot public DotNetWatchContext Context => context; - public abstract bool RequiresBrowserRefresh { get; } + public abstract bool ManagedHotReloadRequiresBrowserRefresh { get; } /// /// Project that's used for launching the application. /// public abstract ProjectGraphNode LaunchingProject { get; } - protected abstract HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); + protected abstract ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); - public async sealed override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + public async sealed override ValueTask CreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) { var browserRefreshServer = await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(LaunchingProject, this, cancellationToken); - if (RequiresBrowserRefresh && browserRefreshServer == null) - { - // Error has been reported - return null; - } - return CreateClients(clientLogger, agentLogger, browserRefreshServer); + var managedClients = (!ManagedHotReloadRequiresBrowserRefresh || browserRefreshServer != null) && IsManagedAgentSupported(LaunchingProject, clientLogger) + ? CreateManagedClients(clientLogger, agentLogger, browserRefreshServer) + : []; + + return new HotReloadClients(managedClients, browserRefreshServer, useRefreshServerToApplyStaticAssets: true); } protected WebAssemblyHotReloadClient CreateWebAssemblyClient(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode clientProject) @@ -71,13 +70,29 @@ public bool IsServerSupported(ProjectGraphNode projectNode, ILogger logger) { if (context.EnvironmentOptions.SuppressBrowserRefresh) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired, EnvironmentVariables.Names.SuppressBrowserRefresh); + } + return false; } if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) { - logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithLevelWhen(LogLevel.Error, RequiresBrowserRefresh)); + if (ManagedHotReloadRequiresBrowserRefresh) + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted); + } + else + { + logger.Log(MessageDescriptor.BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired); + } + return false; } diff --git a/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs b/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs index d30703b87530..ddd4fd25d586 100644 --- a/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs +++ b/src/BuiltInTools/Watch/AppModels/WebServerAppModel.cs @@ -1,6 +1,7 @@ // 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; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -12,9 +13,9 @@ internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraph { public override ProjectGraphNode LaunchingProject => serverProject; - public override bool RequiresBrowserRefresh + public override bool ManagedHotReloadRequiresBrowserRefresh => false; - protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: true), browserRefreshServer); + protected override ImmutableArray<(HotReloadClient client, string name)> CreateManagedClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + => [(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), handlesStaticAssetUpdates: true), "")]; } diff --git a/src/BuiltInTools/Watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/Watch/Aspire/AspireServiceFactory.cs index b7d669a89216..4763f4560f2b 100644 --- a/src/BuiltInTools/Watch/Aspire/AspireServiceFactory.cs +++ b/src/BuiltInTools/Watch/Aspire/AspireServiceFactory.cs @@ -6,12 +6,11 @@ using System.Globalization; using System.Threading.Channels; using Aspire.Tools.Service; -using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal class AspireServiceFactory : IRuntimeProcessLauncherFactory +internal class AspireServiceFactory(ProjectOptions hostProjectOptions) : IRuntimeProcessLauncherFactory { internal sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher { @@ -30,8 +29,8 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r }; private readonly ProjectLauncher _projectLauncher; - private readonly AspireServerService _service; private readonly ProjectOptions _hostProjectOptions; + private readonly AspireServerService _service; private readonly ILogger _logger; /// @@ -106,7 +105,7 @@ public async ValueTask StartProjectAsync(string dcpId, string se { ObjectDisposedException.ThrowIf(_isDisposed, this); - _logger.LogDebug("Starting project: {Path}", projectOptions.ProjectPath); + _logger.LogDebug("Starting: '{Path}'", projectOptions.Representation.ProjectOrEntryPointFilePath); var processTerminationSource = new CancellationTokenSource(); var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); @@ -143,7 +142,7 @@ public async ValueTask StartProjectAsync(string dcpId, string se if (runningProject == null) { // detailed error already reported: - throw new ApplicationException($"Failed to launch project '{projectOptions.ProjectPath}'."); + throw new ApplicationException($"Failed to launch '{projectOptions.Representation.ProjectOrEntryPointFilePath}'."); } await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken); @@ -215,23 +214,16 @@ private async Task TerminateSessionAsync(Session session) } private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) - { - var hostLaunchProfile = _hostProjectOptions.NoLaunchProfile ? null : _hostProjectOptions.LaunchProfileName; - - return new() + => new() { - IsRootProject = false, - ProjectPath = projectLaunchInfo.ProjectPath, + IsMainProject = false, + Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath), WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(), - BuildArguments = _hostProjectOptions.BuildArguments, Command = "run", - CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile), + CommandArguments = GetRunCommandArguments(projectLaunchInfo, _hostProjectOptions.LaunchProfileName.Value), LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [], - LaunchProfileName = projectLaunchInfo.LaunchProfile, - NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile, - TargetFramework = _hostProjectOptions.TargetFramework, + LaunchProfileName = projectLaunchInfo.DisableLaunchProfile ? default : projectLaunchInfo.LaunchProfile, }; - } // internal for testing internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchRequest projectLaunchInfo, string? hostLaunchProfile) @@ -276,13 +268,9 @@ internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchReques } } - public static readonly AspireServiceFactory Instance = new(); - public const string AspireLogComponentName = "Aspire"; public const string AppHostProjectCapability = ProjectCapability.Aspire; - public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) - => projectNode.GetCapabilities().Contains(AppHostProjectCapability) - ? new SessionManager(projectLauncher, hostProjectOptions) - : null; + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher) + => new SessionManager(projectLauncher, hostProjectOptions); } diff --git a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs index 74ce7a91dde2..105b67176421 100644 --- a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs @@ -38,7 +38,7 @@ public void InstallBrowserLaunchTrigger( WebServerProcessStateObserver.Observe(projectNode, processSpec, url => { - if (projectOptions.IsRootProject && + if (projectOptions.IsMainProject && ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.ProjectInstance.GetId())) { // first build iteration of a root project: @@ -127,7 +127,10 @@ private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) { - return (projectOptions.NoLaunchProfile == true - ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, logger)) ?? new(); + var profile = projectOptions.LaunchProfileName.HasValue + ? LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName.Value, logger) + : null; + + return profile ?? new(); } } diff --git a/src/BuiltInTools/Watch/Build/BuildNames.cs b/src/BuiltInTools/Watch/Build/BuildNames.cs index a2d0b790ce0d..144d9ba2998a 100644 --- a/src/BuiltInTools/Watch/Build/BuildNames.cs +++ b/src/BuiltInTools/Watch/Build/BuildNames.cs @@ -23,6 +23,9 @@ internal static class PropertyNames public const string SkipCompilerExecution = nameof(SkipCompilerExecution); public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs); public const string NonExistentFile = nameof(NonExistentFile); + public const string StartupHookSupport = nameof(StartupHookSupport); + public const string PublishTrimmed = nameof(PublishTrimmed); + public const string PublishAot = nameof(PublishAot); } internal static class ItemNames diff --git a/src/BuiltInTools/Watch/Build/BuildReporter.cs b/src/BuiltInTools/Watch/Build/BuildReporter.cs index 6410476dc3c8..de404b1d07f9 100644 --- a/src/BuiltInTools/Watch/Build/BuildReporter.cs +++ b/src/BuiltInTools/Watch/Build/BuildReporter.cs @@ -14,12 +14,13 @@ namespace Microsoft.DotNet.Watch; internal sealed class BuildReporter(ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions) { public ILogger Logger => logger; + public GlobalOptions GlobalOptions => options; public EnvironmentOptions EnvironmentOptions => environmentOptions; public Loggers GetLoggers(string projectPath, string operationName) => new(logger, environmentOptions.GetBinLogPath(projectPath, operationName, options)); - public void ReportWatchedFiles(Dictionary fileItems) + public static void ReportWatchedFiles(ILogger logger, IReadOnlyDictionary fileItems) { logger.Log(MessageDescriptor.WatchingFilesForChanges, fileItems.Count); diff --git a/src/BuiltInTools/Watch/Build/BuildRequest.cs b/src/BuiltInTools/Watch/Build/BuildRequest.cs new file mode 100644 index 000000000000..46e35ff35113 --- /dev/null +++ b/src/BuiltInTools/Watch/Build/BuildRequest.cs @@ -0,0 +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.Execution; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct BuildRequest(ProjectInstance projectInstance, ImmutableArray targets, T data) +{ + public ProjectInstance ProjectInstance { get; } = projectInstance; + public ImmutableArray Targets { get; } = targets; + public T Data { get; } = data; +} + +internal static class BuildRequest +{ + public static BuildRequest Create(ProjectInstance instance, ImmutableArray targets) + => new(instance, targets, data: null); + + public static BuildRequest Create(ProjectInstance instance, ImmutableArray targets, T data) + => new(instance, targets, data); +} diff --git a/src/BuiltInTools/Watch/Build/BuildResult.cs b/src/BuiltInTools/Watch/Build/BuildResult.cs new file mode 100644 index 000000000000..be0ff7d8b622 --- /dev/null +++ b/src/BuiltInTools/Watch/Build/BuildResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Execution; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct BuildResult(IReadOnlyDictionary targetResults, ProjectInstance projectInstance, T data) +{ + public IReadOnlyDictionary TargetResults { get; } = targetResults; + public ProjectInstance ProjectInstance { get; } = projectInstance; + public T Data { get; } = data; + + public bool IsSuccess => TargetResults.Count > 0; +} diff --git a/src/BuiltInTools/Watch/Build/EvaluationResult.cs b/src/BuiltInTools/Watch/Build/EvaluationResult.cs index cc0566e49b39..8d9809c6c7d0 100644 --- a/src/BuiltInTools/Watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/Watch/Build/EvaluationResult.cs @@ -10,16 +10,22 @@ namespace Microsoft.DotNet.Watch; -internal sealed class EvaluationResult(ProjectGraph projectGraph, IReadOnlyDictionary files, IReadOnlyDictionary staticWebAssetsManifests) +internal sealed class EvaluationResult( + LoadedProjectGraph projectGraph, + IReadOnlyDictionary restoredProjectInstances, + IReadOnlyDictionary files, + IReadOnlyDictionary staticWebAssetsManifests, + ProjectBuildManager buildManager) { public readonly IReadOnlyDictionary Files = files; - public readonly ProjectGraph ProjectGraph = projectGraph; + public readonly LoadedProjectGraph ProjectGraph = projectGraph; + public readonly ProjectBuildManager BuildManager = buildManager; public readonly FilePathExclusions ItemExclusions - = projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty; + = projectGraph != null ? FilePathExclusions.Create(projectGraph.Graph) : FilePathExclusions.Empty; private readonly Lazy> _lazyBuildFiles - = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet()); + = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph.Graph) : new HashSet()); private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) => projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) @@ -32,6 +38,9 @@ public IReadOnlySet BuildFiles public IReadOnlyDictionary StaticWebAssetsManifests => staticWebAssetsManifests; + public IReadOnlyDictionary RestoredProjectInstances + => restoredProjectInstances; + public void WatchFiles(FileWatcher fileWatcher) { fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); @@ -43,7 +52,7 @@ public void WatchFiles(FileWatcher fileWatcher) fileWatcher.WatchFiles(BuildFiles); } - public static ImmutableDictionary GetGlobalBuildOptions(IEnumerable buildArguments, EnvironmentOptions environmentOptions) + public static ImmutableDictionary GetGlobalBuildProperties(IEnumerable buildArguments, EnvironmentOptions environmentOptions) { // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md @@ -60,74 +69,111 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera /// /// Loads project graph and performs design-time build. /// - public static EvaluationResult? TryCreate( + public static async ValueTask TryCreateAsync( ProjectGraphFactory factory, - string rootProjectPath, - ILogger logger, - GlobalOptions options, + GlobalOptions globalOptions, EnvironmentOptions environmentOptions, bool restore, CancellationToken cancellationToken) { - var buildReporter = new BuildReporter(logger, options, environmentOptions); + var logger = factory.Logger; + var stopwatch = Stopwatch.StartNew(); - var projectGraph = factory.TryLoadProjectGraph( - rootProjectPath, - logger, - projectGraphRequired: true, - cancellationToken); + var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: true, cancellationToken); if (projectGraph == null) { return null; } - var rootNode = projectGraph.GraphRoots.Single(); + var buildReporter = new BuildReporter(projectGraph.Logger, globalOptions, environmentOptions); + var buildManager = new ProjectBuildManager(projectGraph.ProjectCollection, buildReporter); + + logger.LogDebug("Project graph loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); if (restore) { - using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore")) - { - if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) + stopwatch.Restart(); + + var restoreRequests = projectGraph.Graph.GraphRoots.Select(node => BuildRequest.Create(node.ProjectInstance, [TargetNames.Restore])).ToArray(); + + if (await buildManager.BuildAsync( + restoreRequests, + onFailure: failedInstance => { - logger.LogError("Failed to restore project '{Path}'.", rootProjectPath); - loggers.ReportOutput(); - return null; - } + 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")); } - var fileItems = new Dictionary(); - var staticWebAssetManifests = new Dictionary(); + stopwatch.Restart(); - 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(); + // 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()); - // skip outer build project nodes: - if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") - { - continue; - } + // 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 targets = GetBuildTargets(projectInstance, environmentOptions); - if (targets is []) - { - continue; - } + var buildRequests = + (from node in projectGraph.Graph.ProjectNodesTopologicallySorted + where node.ProjectInstance.GetPropertyValue(PropertyNames.TargetFramework) != "" + let targets = GetBuildTargets(node.ProjectInstance, environmentOptions) + where targets is not [] + select BuildRequest.Create(node.ProjectInstance, [.. targets])).ToArray(); - using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) + var buildResults = await buildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => { - if (!projectInstance.Build(targets, loggers)) - { - logger.LogError("Failed to build project '{Path}'.", projectInstance.FullPath); - loggers.ReportOutput(); - return null; - } - } + 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")); + + ProcessBuildResults(buildResults, logger, out var fileItems, out var staticWebAssetManifests); + + BuildReporter.ReportWatchedFiles(logger, fileItems); + + return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests, buildManager); + } + + private static void ProcessBuildResults( + ImmutableArray> buildResults, + ILogger logger, + out IReadOnlyDictionary fileItems, + out IReadOnlyDictionary staticWebAssetManifests) + { + var fileItemsBuilder = new Dictionary(); + var staticWebAssetManifestsBuilder = new Dictionary(); + + 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) != ".csproj" || projectInstance.GetItems("CscCommandLineArgs").Any()); @@ -135,11 +181,11 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera var projectPath = projectInstance.FullPath; var projectDirectory = Path.GetDirectoryName(projectPath)!; - if (targets.Contains(TargetNames.GenerateComputedBuildStaticWebAssets) && + if (buildResult.TargetResults.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets) && projectInstance.GetIntermediateOutputDirectory() is { } outputDir && StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest) { - staticWebAssetManifests.Add(projectInstance.GetId(), 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) @@ -154,7 +200,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera // 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 (targets.Contains(TargetNames.ResolveScopedCssInputs)) + if (buildResult.TargetResults.ContainsKey(TargetNames.ResolveScopedCssInputs)) { foreach (var item in projectInstance.GetItems(ItemNames.ScopedCssInput)) { @@ -176,9 +222,9 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) { var filePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath)); - if (!fileItems.TryGetValue(filePath, out var existingFile)) + if (!fileItemsBuilder.TryGetValue(filePath, out var existingFile)) { - fileItems.Add(filePath, new FileItem + fileItemsBuilder.Add(filePath, new FileItem { FilePath = filePath, ContainingProjectPaths = [projectPath], @@ -193,9 +239,8 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) } } - buildReporter.ReportWatchedFiles(fileItems); - - return new EvaluationResult(projectGraph, fileItems, staticWebAssetManifests); + fileItems = fileItemsBuilder; + staticWebAssetManifests = staticWebAssetManifestsBuilder; } private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) diff --git a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs index b4f921d875f6..c98def116a2f 100644 --- a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs +++ b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs @@ -39,7 +39,7 @@ public static FilePathExclusions Create(ProjectGraph projectGraph) { // If default items are not enabled exclude just the output directories. - TryAddOutputDir(projectNode.GetOutputDirectory()); + TryAddOutputDir(projectNode.ProjectInstance.GetOutputDirectory()); TryAddOutputDir(projectNode.ProjectInstance.GetIntermediateOutputDirectory()); void TryAddOutputDir(string? dir) diff --git a/src/BuiltInTools/Watch/Build/ProjectNodeMap.cs b/src/BuiltInTools/Watch/Build/LoadedProjectGraph.cs similarity index 89% rename from src/BuiltInTools/Watch/Build/ProjectNodeMap.cs rename to src/BuiltInTools/Watch/Build/LoadedProjectGraph.cs index 69e2e5a0440b..93ed69be35b2 100644 --- a/src/BuiltInTools/Watch/Build/ProjectNodeMap.cs +++ b/src/BuiltInTools/Watch/Build/LoadedProjectGraph.cs @@ -1,21 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Build.Evaluation; using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { - internal readonly struct ProjectNodeMap(ProjectGraph graph, ILogger logger) + internal sealed class LoadedProjectGraph(ProjectGraph graph, ProjectCollection collection, ILogger logger) { - public readonly ProjectGraph Graph = graph; - // full path of proj file to list of nodes representing all target frameworks of the project: public readonly IReadOnlyDictionary> Map = graph.ProjectNodes.GroupBy(n => n.ProjectInstance.FullPath).ToDictionary( keySelector: static g => g.Key, elementSelector: static g => (IReadOnlyList)[.. g]); + public ProjectGraph Graph => graph; + public ILogger Logger => logger; + public ProjectCollection ProjectCollection => collection; + public IReadOnlyList GetProjectNodes(string projectPath) { if (Map.TryGetValue(projectPath, out var rootProjectNodes)) diff --git a/src/BuiltInTools/Watch/Build/ProjectBuildManager.cs b/src/BuiltInTools/Watch/Build/ProjectBuildManager.cs new file mode 100644 index 000000000000..1dff41be1545 --- /dev/null +++ b/src/BuiltInTools/Watch/Build/ProjectBuildManager.cs @@ -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.Immutable; +using System.Diagnostics; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProjectBuildManager(ProjectCollection collection, BuildReporter reporter) +{ + /// + /// Semaphore that ensures we only start one build build at a time per process, which is required by MSBuild. + /// + private static readonly SemaphoreSlim s_buildSemaphore = new(initialCount: 1); + + private static readonly IReadOnlyDictionary s_emptyTargetResults = new Dictionary(); + + public readonly ProjectCollection Collection = collection; + public readonly BuildReporter BuildReporter = reporter; + + /// + /// Executes the specified build requests. + /// + /// Invoked for each project that fails to build. Returns true to continue build or false to cancel. + /// True if all projects built successfully. + public async Task>> BuildAsync( + IReadOnlyList> requests, + Func onFailure, + string operationName, + CancellationToken cancellationToken) + { + Debug.Assert(requests is not []); + var buildRequests = requests.Select(r => new BuildRequestData(r.ProjectInstance, [.. r.Targets])).ToArray(); + + using var loggers = BuildReporter.GetLoggers(buildRequests[0].ProjectFullPath, operationName); + + using var buildCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + await s_buildSemaphore.WaitAsync(cancellationToken); + + var manager = BuildManager.DefaultBuildManager; + using var _ = buildCancellationTokenSource.Token.Register(() => manager.CancelAllSubmissions()); + + var buildParameters = new BuildParameters(Collection) + { + Loggers = loggers, + }; + + manager.BeginBuild(buildParameters); + try + { + var buildTasks = new List>(buildRequests.Length); + + foreach (var request in buildRequests) + { + var taskSource = new TaskCompletionSource(); + + // Queues the build request and immediately returns. The callback is executed when the build completes. + manager.PendBuildRequest(request).ExecuteAsync( + callback: submission => + { + // Cancel on first failure: + if (submission.BuildResult?.OverallResult != BuildResultCode.Success) + { + var projectInstance = (ProjectInstance)submission.AsyncContext!; + + var continueBuild = onFailure(projectInstance); + if (!continueBuild) + { + buildCancellationTokenSource.Cancel(); + taskSource.SetCanceled(); + return; + } + } + + taskSource.SetResult(submission.BuildResult); + }, + context: request.ProjectInstance); + + buildTasks.Add(taskSource.Task); + } + + var results = await Task.WhenAll(buildTasks); + + return [.. results.Select((result, index) => new BuildResult( + (IReadOnlyDictionary?)result?.ResultsByTarget ?? s_emptyTargetResults, + requests[index].ProjectInstance, + requests[index].Data))]; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // build was canceled + loggers.ReportOutput(); + return []; + } + finally + { + manager.EndBuild(); + s_buildSemaphore.Release(); + } + } +} diff --git a/src/BuiltInTools/Watch/Build/ProjectGraphFactory.cs b/src/BuiltInTools/Watch/Build/ProjectGraphFactory.cs index 56ffe6560b0e..067dad7a6459 100644 --- a/src/BuiltInTools/Watch/Build/ProjectGraphFactory.cs +++ b/src/BuiltInTools/Watch/Build/ProjectGraphFactory.cs @@ -2,14 +2,22 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.Versioning; using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; +using Microsoft.DotNet.ProjectTools; using Microsoft.Extensions.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.DotNet.Watch; -internal sealed class ProjectGraphFactory(ImmutableDictionary globalOptions) +internal sealed class ProjectGraphFactory( + ImmutableArray rootProjects, + string? targetFramework, + ImmutableDictionary buildProperties, + ILogger logger) { /// /// Reuse with XML element caching to improve performance. @@ -18,7 +26,7 @@ internal sealed class ProjectGraphFactory(ImmutableDictionary gl /// https://github.com/dotnet/msbuild/blob/b6f853defccd64ae1e9c7cf140e7e4de68bff07c/src/Build/Definition/ProjectCollection.cs#L343-L354 /// private readonly ProjectCollection _collection = new( - globalProperties: globalOptions, + globalProperties: buildProperties, loggers: [], remoteLoggers: [], ToolsetDefinitionLocations.Default, @@ -28,23 +36,37 @@ internal sealed class ProjectGraphFactory(ImmutableDictionary gl useAsynchronousLogging: false, reuseProjectRootElementCache: true); + private readonly string _targetFramework = targetFramework ?? GetProductTargetFramework(); + + public ILogger Logger => logger; + + private static string GetProductTargetFramework() + { + var attribute = typeof(VirtualProjectBuilder).Assembly.GetCustomAttribute() ?? throw new InvalidOperationException(); + var version = new FrameworkName(attribute.FrameworkName).Version; + return $"net{version.Major}.{version.Minor}"; + } + /// /// Tries to create a project graph by running the build evaluation phase on the . /// - public ProjectGraph? TryLoadProjectGraph( - string rootProjectFile, - ILogger logger, - bool projectGraphRequired, - CancellationToken cancellationToken) + public LoadedProjectGraph? TryLoadProjectGraph(bool projectGraphRequired, CancellationToken cancellationToken) { - var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); + var entryPoints = rootProjects.Select(p => new ProjectGraphEntryPoint(p.ProjectGraphPath, buildProperties)); try { - return new ProjectGraph([entryPoint], _collection, projectInstanceFactory: null, cancellationToken); + return new LoadedProjectGraph( + new ProjectGraph(entryPoints, _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken), + _collection, + logger); + } + catch (ProjectCreationFailedException) + { + // Errors have already been reported. } catch (Exception e) when (e is not OperationCanceledException) { - // ProejctGraph aggregates OperationCanceledException exception, + // ProjectGraph aggregates OperationCanceledException exception, // throw here to propagate the cancellation. cancellationToken.ThrowIfCancellationRequested(); @@ -54,7 +76,10 @@ internal sealed class ProjectGraphFactory(ImmutableDictionary gl { foreach (var inner in innerExceptions) { - Report(inner); + if (inner is not ProjectCreationFailedException) + { + Report(inner); + } } } else @@ -77,4 +102,48 @@ void Report(Exception e) return null; } + + private ProjectInstance CreateProjectInstance(string projectPath, Dictionary globalProperties, ProjectCollection projectCollection, ILogger logger) + { + if (!File.Exists(projectPath)) + { + var entryPointFilePath = Path.ChangeExtension(projectPath, ".cs"); + if (!File.Exists(entryPointFilePath)) + { + // `dotnet build` reports a warning when the reference project is missing. + // However, ProjectGraph doesn't allow us to return null to skip the project so we need to be stricter. + logger.LogError("The project file could not be loaded. Could not find a part of the path '{Path}'.", projectPath); + throw new ProjectCreationFailedException(); + } + + var builder = new VirtualProjectBuilder(entryPointFilePath, _targetFramework); + var anyError = false; + + builder.CreateProjectInstance( + projectCollection, + (sourceFile, textSpan, message) => + { + anyError = true; + logger.LogError("{Location}: {Message}", sourceFile.GetLocationString(textSpan), message); + }, + out var projectInstance, + out _); + + if (anyError) + { + throw new ProjectCreationFailedException(); + } + + return projectInstance; + } + + return new ProjectInstance( + projectPath, + globalProperties, + toolsVersion: "Current", + subToolsetVersion: null, + projectCollection); + } + + private sealed class ProjectCreationFailedException() : Exception(); } diff --git a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs index 111dff988e47..4adce83dc6bd 100644 --- a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs @@ -51,8 +51,8 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVe public static bool IsWebApp(this ProjectGraphNode projectNode) => projectNode.GetCapabilities().Any(static value => value is ProjectCapability.AspNetCore or ProjectCapability.WebAssembly); - public static string? GetOutputDirectory(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; + public static string? GetOutputDirectory(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(project.Directory, path)) : null; public static string GetAssemblyName(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); diff --git a/src/BuiltInTools/Watch/Build/ProjectRepresentation.cs b/src/BuiltInTools/Watch/Build/ProjectRepresentation.cs new file mode 100644 index 000000000000..76526a8a0cb6 --- /dev/null +++ b/src/BuiltInTools/Watch/Build/ProjectRepresentation.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.ProjectTools; + +namespace Microsoft.DotNet.Watch; + +/// +/// Project can be reprented by project file or by entry point file (for single-file apps). +/// +internal readonly struct ProjectRepresentation(string projectGraphPath, string? projectPath, string? entryPointFilePath) +{ + /// + /// Path used in Project Graph (may be virtual). + /// + public readonly string ProjectGraphPath = projectGraphPath; + + /// + /// Path to an physical (non-virtual) project, if available. + /// + public readonly string? PhysicalPath = projectPath; + + /// + /// Path to an entry point file, if available. + /// + public readonly string? EntryPointFilePath = entryPointFilePath; + + public ProjectRepresentation(string? projectPath, string? entryPointFilePath) + : this(projectPath ?? VirtualProjectBuilder.GetVirtualProjectPath(entryPointFilePath!), projectPath, entryPointFilePath) + { + } + + public string ProjectOrEntryPointFilePath + => PhysicalPath ?? EntryPointFilePath!; + + public string GetContainingDirectory() + => Path.GetDirectoryName(ProjectOrEntryPointFilePath)!; + + public static ProjectRepresentation FromProjectOrEntryPointFilePath(string projectOrEntryPointFilePath) + => string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".csproj", StringComparison.OrdinalIgnoreCase) + ? new(projectPath: projectOrEntryPointFilePath, entryPointFilePath: null) + : new(projectPath: null, entryPointFilePath: projectOrEntryPointFilePath); + + public ProjectRepresentation WithProjectGraphPath(string projectGraphPath) + => new(projectGraphPath, PhysicalPath, EntryPointFilePath); +} diff --git a/src/BuiltInTools/Watch/Context/DotNetWatchContext.cs b/src/BuiltInTools/Watch/Context/DotNetWatchContext.cs index f7caada6824c..a8b712f0de37 100644 --- a/src/BuiltInTools/Watch/Context/DotNetWatchContext.cs +++ b/src/BuiltInTools/Watch/Context/DotNetWatchContext.cs @@ -1,6 +1,7 @@ // 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.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -18,7 +19,25 @@ internal sealed class DotNetWatchContext : IDisposable public required ILoggerFactory LoggerFactory { get; init; } public required ProcessRunner ProcessRunner { get; init; } - public required ProjectOptions RootProjectOptions { get; init; } + /// + /// Roots of the project graph to watch. + /// + public required ImmutableArray RootProjects { get; init; } + + /// + /// Options for launching a main project. If null no main project is being launched. + /// + public required ProjectOptions? MainProjectOptions { get; init; } + + /// + /// Default target framework. + /// + public required string? TargetFramework { get; init; } + + /// + /// Additional arguments passed to `dotnet build` when building projects. + /// + public required IReadOnlyList BuildArguments { get; init; } public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; } public required BrowserLauncher BrowserLauncher { get; init; } diff --git a/src/BuiltInTools/Watch/Context/ProjectOptions.cs b/src/BuiltInTools/Watch/Context/ProjectOptions.cs index 20eb2eed69eb..ee8bde50abc5 100644 --- a/src/BuiltInTools/Watch/Context/ProjectOptions.cs +++ b/src/BuiltInTools/Watch/Context/ProjectOptions.cs @@ -5,13 +5,20 @@ namespace Microsoft.DotNet.Watch; internal sealed record ProjectOptions { - public required bool IsRootProject { get; init; } - public required string ProjectPath { get; init; } + public required ProjectRepresentation Representation { get; init; } + + /// + /// True if the project has been launched by watch in the main iteration loop. + /// + public required bool IsMainProject { get; init; } + public required string WorkingDirectory { get; init; } - public required string? TargetFramework { get; init; } - public required IReadOnlyList BuildArguments { get; init; } - public required bool NoLaunchProfile { get; init; } - public required string? LaunchProfileName { get; init; } + + /// + /// No value indicates that no launch profile should be used. + /// Null value indicates that the default launch profile should be used. + /// + public required Optional LaunchProfileName { get; init; } /// /// Command to use to launch the project. diff --git a/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs b/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs index 71e339419a1c..631b13ae5d87 100644 --- a/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs +++ b/src/BuiltInTools/Watch/FileWatcher/ChangeKind.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; + namespace Microsoft.DotNet.Watch; internal enum ChangeKind @@ -10,6 +12,18 @@ internal enum ChangeKind Delete } +internal static class ChangeKindExtensions +{ + public static HotReloadFileChangeKind Convert(this ChangeKind changeKind) => + changeKind switch + { + ChangeKind.Update => HotReloadFileChangeKind.Update, + ChangeKind.Add => HotReloadFileChangeKind.Add, + ChangeKind.Delete => HotReloadFileChangeKind.Delete, + _ => throw new InvalidOperationException() + }; +} + internal readonly record struct ChangedFile(FileItem Item, ChangeKind Kind); internal readonly record struct ChangedPath(string Path, ChangeKind Kind); diff --git a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs index 1e445f5e9cb9..1c5bfbd8aa98 100644 --- a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs +++ b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs @@ -21,6 +21,7 @@ internal class FileWatcher(ILogger logger, EnvironmentOptions environmentOptions public event Action? OnFileChange; public bool SuppressEvents { get; set; } + public DateTime StartTime { get; set; } public void Dispose() { @@ -205,20 +206,22 @@ void FileChangedCallback(ChangedPath change) return change; } - public static async ValueTask WaitForFileChangeAsync(string filePath, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken) + public static async ValueTask WaitForFileChangeAsync(IEnumerable filePaths, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken) { using var watcher = new FileWatcher(logger, environmentOptions); - watcher.WatchContainingDirectories([filePath], includeSubdirectories: false); + watcher.WatchContainingDirectories(filePaths, includeSubdirectories: false); + + var pathSet = filePaths.ToHashSet(); var fileChange = await watcher.WaitForFileChangeAsync( - acceptChange: change => change.Path == filePath, + acceptChange: change => pathSet.Contains(change.Path), startedWatching, cancellationToken); if (fileChange != null) { - logger.LogInformation("File changed: {FilePath}", filePath); + logger.LogInformation("File changed: {FilePath}", fileChange.Value.Path); } } } diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs index 360d49ebf8ae..ff6ae3d9ff07 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -15,7 +15,7 @@ namespace Microsoft.DotNet.Watch { internal sealed class CompilationHandler : IDisposable { - public readonly IncrementalMSBuildWorkspace Workspace; + public readonly HotReloadMSBuildWorkspace Workspace; private readonly DotNetWatchContext _context; private readonly HotReloadService _hotReloadService; @@ -27,7 +27,7 @@ internal sealed class CompilationHandler : IDisposable private readonly object _runningProjectsAndUpdatesGuard = new(); /// - /// Projects that have been launched and to which we apply changes. + /// Projects that have been launched and to which we apply changes. /// private ImmutableDictionary> _runningProjects = ImmutableDictionary>.Empty; @@ -37,11 +37,18 @@ internal sealed class CompilationHandler : IDisposable private ImmutableList _previousUpdates = []; private bool _isDisposed; + private int _solutionUpdateId; + + /// + /// Current set of project instances indexed by . + /// Updated whenever the project graph changes. + /// + private ImmutableDictionary> _projectInstances = []; public CompilationHandler(DotNetWatchContext context) { _context = context; - Workspace = new IncrementalMSBuildWorkspace(context.Logger); + Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } @@ -54,10 +61,10 @@ public void Dispose() private ILogger Logger => _context.Logger; - public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) + public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken) { Logger.LogDebug("Terminating remaining child processes."); - await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); + await TerminatePeripheralProcessesAsync(projectPaths: null, cancellationToken); Dispose(); } @@ -103,6 +110,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) ProjectGraphNode projectNode, ProjectOptions projectOptions, HotReloadClients clients, + ILogger clientLogger, ProcessSpec processSpec, RestartOperation restartOperation, CancellationTokenSource processTerminationSource, @@ -141,7 +149,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) }; var launchResult = new ProcessLaunchResult(); - var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); + var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clientLogger, launchResult, processTerminationSource.Token); if (launchResult.ProcessId == null) { // error already reported @@ -154,18 +162,19 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) { // Wait for agent to create the name pipe and send capabilities over. // the agent blocks the app execution until initial updates are applied (if any). - var capabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); + var managedCodeUpdateCapabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); var runningProject = new RunningProject( projectNode, projectOptions, clients, + clientLogger, runningProcess, launchResult.ProcessId.Value, processExitedSource: processExitedSource, processTerminationSource: processTerminationSource, restartOperation: restartOperation, - capabilities); + managedCodeUpdateCapabilities); // ownership transferred to running project: disposables.Items.Clear(); @@ -178,7 +187,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) // and apply them before adding it to running processes. // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date. var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); - if (updatesToApply.Any()) + if (updatesToApply.Any() && clients.IsManagedAgentSupported) { await await clients.ApplyManagedCodeUpdatesAsync( ToManagedCodeUpdates(updatesToApply), @@ -215,20 +224,23 @@ await await clients.ApplyManagedCodeUpdatesAsync( } } - clients.OnRuntimeRudeEdit += (code, message) => + if (clients.IsManagedAgentSupported) { - // fire and forget: - _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); - }; + clients.OnRuntimeRudeEdit += (code, message) => + { + // fire and forget: + _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); + }; - // Notifies the agent that it can unblock the execution of the process: - await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); + // Notifies the agent that it can unblock the execution of the process: + await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); - // If non-empty solution is loaded into the workspace (a Hot Reload session is active): - if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) - { - // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. - PrepareCompilations(currentSolution, projectPath, cancellationToken); + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): + if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + { + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + PrepareCompilations(currentSolution, projectPath, cancellationToken); + } } return runningProject; @@ -243,7 +255,7 @@ await await clients.ApplyManagedCodeUpdatesAsync( private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken) { - var logger = runningProject.Clients.ClientLogger; + var logger = runningProject.ClientLogger; try { @@ -280,7 +292,7 @@ private ImmutableArray GetAggregateCapabilities() { var capabilities = _runningProjects .SelectMany(p => p.Value) - .SelectMany(p => p.Capabilities) + .SelectMany(p => p.ManagedCodeUpdateCapabilities) .Distinct(StringComparer.Ordinal) .Order() .ToImmutableArray(); @@ -302,13 +314,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C } } - public async ValueTask<( - ImmutableArray projectUpdates, - ImmutableArray projectsToRebuild, - ImmutableArray projectsToRedeploy, - ImmutableArray projectsToRestart)> HandleManagedCodeChangesAsync( - bool autoRestart, + public async ValueTask GetManagedCodeUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, Func, CancellationToken, Task> restartPrompt, + bool autoRestart, CancellationToken cancellationToken) { var currentSolution = Workspace.CurrentSolution; @@ -332,7 +341,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C // changes and await the next file change. // Note: CommitUpdate/DiscardUpdate is not expected to be called. - return ([], [], [], []); + return; } var projectsToPromptForRestart = @@ -348,7 +357,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C Logger.Log(MessageDescriptor.HotReloadSuspended); await Task.Delay(-1, cancellationToken); - return ([], [], [], []); + return; } // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. @@ -356,62 +365,94 @@ private static void PrepareCompilations(Solution solution, string projectPath, C DiscardPreviousUpdates(updates.ProjectsToRebuild); - var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); - var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates); + builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!)); + builder.ProjectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!)); // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. - var projectsToRestart = updates.ProjectsToRestart.IsEmpty - ? [] - : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - - return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart); + if (!updates.ProjectsToRestart.IsEmpty) + { + builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken)); + } } - public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, Stopwatch stopwatch, CancellationToken cancellationToken) + public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAsync( + IReadOnlyList managedCodeUpdates, + IReadOnlyDictionary> staticAssetUpdates, + Stopwatch stopwatch, + CancellationToken cancellationToken) { - Debug.Assert(!updates.IsEmpty); + var applyTasks = new List(); - ImmutableDictionary> projectsToUpdate; - lock (_runningProjectsAndUpdatesGuard) + if (managedCodeUpdates is not []) { - // Adding the updates makes sure that all new processes receive them before they are added to running processes. - _previousUpdates = _previousUpdates.AddRange(updates); - - // Capture the set of processes that do not have the currently calculated deltas yet. - projectsToUpdate = _runningProjects; - } + ImmutableDictionary> projectsToUpdate; + lock (_runningProjectsAndUpdatesGuard) + { + // Adding the updates makes sure that all new processes receive them before they are added to running processes. + _previousUpdates = _previousUpdates.AddRange(managedCodeUpdates); - // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. - // The process may load any of the binaries using MEF or some other runtime dependency loader. + // Capture the set of processes that do not have the currently calculated deltas yet. + projectsToUpdate = _runningProjects; + } - var applyTasks = new List(); + // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. + // The process may load any of the binaries using MEF or some other runtime dependency loader. - foreach (var (_, projects) in projectsToUpdate) - { - foreach (var runningProject in projects) + foreach (var (_, projects) in projectsToUpdate) { - // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. - var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( - ToManagedCodeUpdates(updates), - applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, - cancellationToken); + foreach (var runningProject in projects) + { + Debug.Assert(runningProject.Clients.IsManagedAgentSupported); + + // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown. + var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync( + ToManagedCodeUpdates(managedCodeUpdates), + applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, + cancellationToken); - applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask)); + applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask)); + } } } + // Creating apply tasks involves reading static assets from disk. Parallelize this IO. + var staticAssetApplyTaskProducers = new List>(); + + foreach (var (runningProject, assets) in staticAssetUpdates) + { + // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, + // but for consistency with managed code updates we only cancel when the process exits. + staticAssetApplyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( + assets, + applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, + cancellationToken)); + } + + applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers)); + // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.ManagedCodeChangesApplied); + _ = CompleteApplyOperationAsync(applyTasks, stopwatch, managedCodeUpdates.Count > 0, staticAssetUpdates.Count > 0); } - private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, MessageDescriptor message) + private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Stopwatch stopwatch, bool hasManagedCodeUpdates, bool hasStaticAssetUpdates) { try { await Task.WhenAll(applyTasks); - _context.Logger.Log(message, stopwatch.ElapsedMilliseconds); + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + if (hasManagedCodeUpdates) + { + _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); + } + + if (hasStaticAssetUpdates) + { + _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); + } } catch (Exception e) { @@ -419,7 +460,7 @@ private async Task CompleteApplyOperationAsync(IEnumerable applyTasks, Sto if (e is not OperationCanceledException) { - _context.Logger.LogError("Failed to apply updates: {Exception}", e.ToString()); + _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); } } } @@ -449,7 +490,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im break; case HotReloadService.Status.NoChangesToApply: - Logger.Log(MessageDescriptor.NoCSharpChangesToApply); + Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply); break; case HotReloadService.Status.Blocked: @@ -580,20 +621,20 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbos } } - private static readonly string[] s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; + private static readonly ImmutableArray s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; private static bool HasScopedCssTargets(ProjectInstance projectInstance) => s_targets.All(projectInstance.Targets.ContainsKey); - public async ValueTask HandleStaticAssetChangesAsync( + public async ValueTask GetStaticAssetUpdatesAsync( + HotReloadProjectUpdatesBuilder builder, IReadOnlyList files, - ProjectNodeMap projectMap, - IReadOnlyDictionary manifests, + EvaluationResult evaluationResult, Stopwatch stopwatch, CancellationToken cancellationToken) { var assets = new Dictionary>(); - var projectInstancesToRegenerate = new HashSet(); + var projectInstancesToRegenerate = new HashSet(); foreach (var changedFile in files) { @@ -607,7 +648,7 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var containingProjectPath in file.ContainingProjectPaths) { - if (!projectMap.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) + if (!evaluationResult.ProjectGraph.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) { // Shouldn't happen. Logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); @@ -624,13 +665,13 @@ public async ValueTask HandleStaticAssetChangesAsync( continue; } - projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance); + projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance.GetId()); } foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) { var applicationProjectInstance = referencingProjectNode.ProjectInstance; - if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects)) + if (!TryGetRunningProject(applicationProjectInstance.FullPath, out _)) { continue; } @@ -646,13 +687,13 @@ public async ValueTask HandleStaticAssetChangesAsync( continue; } - projectInstancesToRegenerate.Add(applicationProjectInstance); + projectInstancesToRegenerate.Add(applicationProjectInstance.GetId()); var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName( applicationProjectFilePath: applicationProjectInstance.FullPath, containingProjectFilePath: containingProjectNode.ProjectInstance.FullPath); - if (!manifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) + if (!evaluationResult.StaticWebAssetsManifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) { // Shouldn't happen. Logger.LogWarning("[{Project}] Static web asset manifest not found.", containingProjectNode.GetDisplayName()); @@ -705,28 +746,28 @@ public async ValueTask HandleStaticAssetChangesAsync( HashSet? failedApplicationProjectInstances = null; if (projectInstancesToRegenerate.Count > 0) { - var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); + Logger.LogDebug("Regenerating scoped CSS bundles."); - // Note: MSBuild only allows one build at a time in a process. - foreach (var projectInstance in projectInstancesToRegenerate) - { - Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName()); - - using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss"); + // Deep copy instances so that we don't pollute the project graph: + var buildRequests = projectInstancesToRegenerate + .Select(instanceId => BuildRequest.Create(evaluationResult.RestoredProjectInstances[instanceId].DeepCopy(), s_targets)) + .ToArray(); - // Deep copy so that we don't pollute the project graph: - if (!projectInstance.DeepCopy().Build(s_targets, loggers)) + _ = await evaluationResult.BuildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => { - loggers.ReportOutput(); + Logger.LogWarning("[{ProjectName}] Failed to regenerate scoped CSS bundle.", failedInstance.GetDisplayName()); failedApplicationProjectInstances ??= []; - failedApplicationProjectInstances.Add(projectInstance); - } - } - } + failedApplicationProjectInstances.Add(failedInstance); - // Creating apply tasks involves reading static assets from disk. Parallelize this IO. - var applyTaskProducers = new List>(); + // continue build + return true; + }, + operationName: "ScopedCss", + cancellationToken); + } foreach (var (applicationProjectInstance, instanceAssets) in assets) { @@ -742,31 +783,35 @@ public async ValueTask HandleStaticAssetChangesAsync( foreach (var runningProject in runningProjects) { - // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok, - // but for consistency with managed code updates we only cancel when the process exits. - applyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync( - instanceAssets.Values, - applyOperationCancellationToken: runningProject.ProcessExitedCancellationToken, - cancellationToken)); + if (!builder.StaticAssetsToUpdate.TryGetValue(runningProject, out var updatesPerRunningProject)) + { + builder.StaticAssetsToUpdate.Add(runningProject, updatesPerRunningProject = []); + } + + if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported) + { + // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled). + builder.ProjectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); + builder.ProjectsToRestart.Add(runningProject); + } + else + { + updatesPerRunningProject.AddRange(instanceAssets.Values); + } } } - - var applyTasks = await Task.WhenAll(applyTaskProducers); - - // fire and forget: - _ = CompleteApplyOperationAsync(applyTasks, stopwatch, MessageDescriptor.StaticAssetsChangesApplied); } /// - /// Terminates all processes launched for non-root projects with , - /// or all running non-root project processes if is null. + /// Terminates all processes launched for peripheral projects with , + /// or all running peripheral project processes if is null. /// /// Removes corresponding entries from . /// - /// Does not terminate the root project. + /// Does not terminate the main project. /// - /// All processes (including root) to be restarted. - internal async ValueTask> TerminateNonRootProcessesAsync( + /// All processes (including main) to be restarted. + internal async ValueTask> TerminatePeripheralProcessesAsync( IEnumerable? projectPaths, CancellationToken cancellationToken) { ImmutableArray projectsToRestart = []; @@ -781,7 +826,7 @@ internal async ValueTask> TerminateNonRootProcess // Do not terminate root process at this time - it would signal the cancellation token we are currently using. // The process will be restarted later on. // Wait for all processes to exit to release their resources, so we can rebuild. - await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); + await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsMainProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); return projectsToRestart; } @@ -830,7 +875,72 @@ public bool TryGetRunningProject(string projectPath, out ImmutableArray> projects, Func action, CancellationToken cancellationToken) => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken); - private static ImmutableArray ToManagedCodeUpdates(ImmutableArray updates) + private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; + + private static ImmutableDictionary> CreateProjectInstanceMap(ProjectGraph graph) + => graph.ProjectNodes + .GroupBy(static node => node.ProjectInstance.FullPath) + .ToImmutableDictionary( + keySelector: static group => group.Key, + elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray()); + + public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken) + { + Logger.LogInformation("Loading projects ..."); + var stopwatch = Stopwatch.StartNew(); + + _projectInstances = CreateProjectInstanceMap(projectGraph); + + var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken); + await SolutionUpdatedAsync(solution, "project update", cancellationToken); + + Logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + } + + public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) + { + var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken); + await SolutionUpdatedAsync(solution, "document update", cancellationToken); + } + + private Task SolutionUpdatedAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken) + => ReportSolutionFilesAsync(newSolution, Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken); + + private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) + { + Logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); + + if (!Logger.IsEnabled(LogLevel.Trace)) + { + return; + } + + foreach (var project in solution.Projects) + { + Logger.LogDebug(" Project: {Path}", project.FilePath); + + foreach (var document in project.Documents) + { + await InspectDocumentAsync(document, "Document").ConfigureAwait(false); + } + + foreach (var document in project.AdditionalDocuments) + { + await InspectDocumentAsync(document, "Additional").ConfigureAwait(false); + } + + foreach (var document in project.AnalyzerConfigDocuments) + { + await InspectDocumentAsync(document, "Config").ConfigureAwait(false); + } + } + + async ValueTask InspectDocumentAsync(TextDocument document, string kind) + { + var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + Logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); + } + } } } diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 93cde1a44a06..af437b3f61cb 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Text.Encodings.Web; +using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; @@ -45,9 +46,12 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun } _designTimeBuildGraphFactory = new ProjectGraphFactory( - EvaluationResult.GetGlobalBuildOptions( - context.RootProjectOptions.BuildArguments, - context.EnvironmentOptions)); + context.RootProjects, + context.TargetFramework, + buildProperties: EvaluationResult.GetGlobalBuildProperties( + context.BuildArguments, + context.EnvironmentOptions), + context.BuildLogger); } public async Task WatchAsync(CancellationToken shutdownCancellationToken) @@ -73,24 +77,22 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) { Interlocked.Exchange(ref forceRestartCancellationSource, new CancellationTokenSource())?.Dispose(); - using var rootProcessTerminationSource = new CancellationTokenSource(); + using var mainProcessTerminationSource = new CancellationTokenSource(); - // This source will signal when the user cancels (either Ctrl+R or Ctrl+C) or when the root process terminates: - using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token, rootProcessTerminationSource.Token); + // This source will signal when the user cancels (either Ctrl+R or Ctrl+C) or when the main process terminates: + using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token, mainProcessTerminationSource.Token); var iterationCancellationToken = iterationCancellationSource.Token; var waitForFileChangeBeforeRestarting = true; EvaluationResult? evaluationResult = null; - RunningProject? rootRunningProject = null; + RunningProject? mainRunningProject = null; IRuntimeProcessLauncher? runtimeProcessLauncher = null; CompilationHandler? compilationHandler = null; Action? fileChangedCallback = null; try { - var rootProjectOptions = _context.RootProjectOptions; - - var buildSucceeded = await BuildProjectAsync(rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); + var buildSucceeded = await BuildProjectsAsync(_context.RootProjects, _context.BuildArguments, iterationCancellationToken); if (!buildSucceeded) { continue; @@ -98,76 +100,70 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) // Evaluate the target to find out the set of files to watch. // In case the app fails to start due to build or other error we can wait for these files to change. - // Avoid restore since the build above already restored the root project. - evaluationResult = await EvaluateRootProjectAsync(restore: false, iterationCancellationToken); + // Avoid restore since the build above already restored all root projects. + evaluationResult = await EvaluateProjectGraphAsync(restore: false, iterationCancellationToken); - var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single(); + compilationHandler = new CompilationHandler(_context); + var projectLauncher = new ProjectLauncher(_context, evaluationResult.ProjectGraph, compilationHandler, iteration); + evaluationResult.ItemExclusions.Report(_context.Logger); - // use normalized MSBuild path so that we can index into the ProjectGraph - rootProjectOptions = rootProjectOptions with { ProjectPath = rootProject.ProjectInstance.FullPath }; + var mainProjectOptions = _context.MainProjectOptions; + var mainProject = (mainProjectOptions != null) ? evaluationResult.ProjectGraph.Graph.GraphRoots.Single() : null; var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; - var rootProjectCapabilities = rootProject.GetCapabilities(); - if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability)) + + if (mainProject?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true) { - runtimeProcessLauncherFactory ??= AspireServiceFactory.Instance; + Debug.Assert(mainProjectOptions != null); + runtimeProcessLauncherFactory ??= new AspireServiceFactory(mainProjectOptions); _context.Logger.LogDebug("Using Aspire process launcher."); } - var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); - compilationHandler = new CompilationHandler(_context); - var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration); - evaluationResult.ItemExclusions.Report(_context.Logger); + runtimeProcessLauncher = runtimeProcessLauncherFactory?.Create(projectLauncher); - runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); - if (runtimeProcessLauncher != null) + if (mainProjectOptions != null) { - var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables(); - rootProjectOptions = rootProjectOptions with + if (runtimeProcessLauncher != null) { - LaunchEnvironmentVariables = [.. rootProjectOptions.LaunchEnvironmentVariables, .. launcherEnvironment] - }; - } - - rootRunningProject = await projectLauncher.TryLaunchProcessAsync( - rootProjectOptions, - rootProcessTerminationSource, - onOutput: null, - onExit: null, - restartOperation: new RestartOperation(_ => default), // the process will automatically restart - iterationCancellationToken); - - if (rootRunningProject == null) - { - // error has been reported: - waitForFileChangeBeforeRestarting = false; - return; - } + mainProjectOptions = mainProjectOptions with + { + LaunchEnvironmentVariables = [.. mainProjectOptions.LaunchEnvironmentVariables, .. runtimeProcessLauncher.GetEnvironmentVariables()] + }; + } - // Cancel iteration as soon as the root process exits, so that we don't spent time loading solution, etc. when the process is already dead. - rootRunningProject.ProcessExitedCancellationToken.Register(iterationCancellationSource.Cancel); + mainRunningProject = await projectLauncher.TryLaunchProcessAsync( + mainProjectOptions, + mainProcessTerminationSource, + onOutput: null, + //onExit: (_, _) => + //{ + // // Process exited: cancel the iteration, but wait for a file change before starting a new one + // waitForFileChangeBeforeRestarting = true; + // iterationCancellationSource.Cancel(); + // return ValueTask.CompletedTask; + //}, + onExit: null, + restartOperation: new RestartOperation(_ => default), // the process will automatically restart + iterationCancellationToken); - if (shutdownCancellationToken.IsCancellationRequested) - { - // Ctrl+C: - return; - } + if (mainRunningProject == null) + { + // error has been reported: + waitForFileChangeBeforeRestarting = false; + return; + } - if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken)) - { - // Process might have exited while we were trying to communicate with it. - // Cancel the iteration, but wait for a file change before starting a new one. - iterationCancellationSource.Cancel(); - iterationCancellationSource.Token.ThrowIfCancellationRequested(); - } + // Cancel iteration as soon as the main process exits, so that we don't spent time loading solution, etc. when the process is already dead. + mainRunningProject.ProcessExitedCancellationToken.Register(iterationCancellationSource.Cancel); - if (shutdownCancellationToken.IsCancellationRequested) - { - // Ctrl+C: - return; + if (shutdownCancellationToken.IsCancellationRequested) + { + // Ctrl+C: + return; + } } - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); // Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition // when the EnC session captures content of the file after the changes has already been made. @@ -197,8 +193,8 @@ void FileChangedCallback(ChangedPath change) fileWatcher.OnFileChange += fileChangedCallback; _context.Logger.Log(MessageDescriptor.WaitingForChanges); - // Hot Reload loop - exits when the root process needs to be restarted. - bool extendTimeout = false; + // Hot Reload loop - exits when the main process needs to be restarted. + var extendTimeout = false; while (true) { try @@ -210,16 +206,26 @@ void FileChangedCallback(ChangedPath change) // Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check // for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again. - _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken); + if (mainRunningProject != null) + { + try + { + _ = await mainRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken); - // Process exited: cancel the iteration, but wait for a file change before starting a new one - waitForFileChangeBeforeRestarting = true; - iterationCancellationSource.Cancel(); - break; - } - catch (TimeoutException) - { - // check for changed files + // Process exited: cancel the iteration, but wait for a file change before starting a new one + waitForFileChangeBeforeRestarting = true; + iterationCancellationSource.Cancel(); + break; + } + catch (TimeoutException) + { + // check for changed files + } + } + else + { + await Task.Delay(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken); + } } catch (OperationCanceledException) { @@ -247,80 +253,33 @@ void FileChangedCallback(ChangedPath change) continue; } - if (!rootProjectCapabilities.Contains("SupportsHotReload")) - { - _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName()); - - // file change already detected - waitForFileChangeBeforeRestarting = false; - iterationCancellationSource.Cancel(); - break; - } - - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.Main); + var updates = new HotReloadProjectUpdatesBuilder(); var stopwatch = Stopwatch.StartNew(); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); - await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, stopwatch, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); - - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); + await compilationHandler.GetStaticAssetUpdatesAsync(updates, changedFiles, evaluationResult, stopwatch, iterationCancellationToken); - var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( - autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, + await compilationHandler.GetManagedCodeUpdatesAsync( + updates, restartPrompt: async (projectNames, cancellationToken) => { - if (_rudeEditRestartPrompt != null) - { - // stop before waiting for user input: - stopwatch.Stop(); - - string question; - if (runtimeProcessLauncher == null) - { - question = "Do you want to restart your app?"; - } - else - { - _context.Logger.LogInformation("Affected projects:"); - - foreach (var projectName in projectNames.OrderBy(n => n)) - { - _context.Logger.LogInformation(" {ProjectName}", projectName); - } - - question = "Do you want to restart these projects?"; - } - - return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); - } - - _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); - - foreach (var projectName in projectNames) - { - _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); - } - - return true; + // stop before waiting for user input: + stopwatch.Stop(); + var result = await RestartPrompt(projectNames, runtimeProcessLauncher, cancellationToken); + stopwatch.Start(); + return result; }, + autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); - - stopwatch.Stop(); - - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.Main); - - // Terminate root process if it had rude edits or is non-reloadable. - if (projectsToRestart.SingleOrDefault(project => project.Options.IsRootProject) is { } rootProjectToRestart) + // Terminate main process if it had rude edits or is non-reloadable. + if (updates.ProjectsToRestart.SingleOrDefault(project => project.Options.IsMainProject) is { } mainProjectToRestart) { - // Triggers rootRestartCancellationToken. + // Triggers mainRestartCancellationToken. waitForFileChangeBeforeRestarting = false; break; } - if (!projectsToRebuild.IsEmpty) + if (updates.ProjectsToRebuild is not []) { while (true) { @@ -330,18 +289,7 @@ void FileChangedCallback(ChangedPath change) fileWatcher.SuppressEvents = true; try { - // Build projects sequentially to avoid failed attempts to overwrite dependent project outputs. - // TODO: Ideally, dotnet build would be able to build multiple projects. https://github.com/dotnet/sdk/issues/51311 - var success = true; - foreach (var projectPath in projectsToRebuild) - { - success = await BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); - if (!success) - { - break; - } - } - + var success = await BuildProjectsAsync([.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], _context.BuildArguments, iterationCancellationToken); if (success) { break; @@ -362,39 +310,35 @@ void FileChangedCallback(ChangedPath change) // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update. // Apply them to the workspace. - _ = await CaptureChangedFilesSnapshot(projectsToRebuild); + _ = await CaptureChangedFilesSnapshot(updates.ProjectsToRebuild); - _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length); + _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updates.ProjectsToRebuild.Count); } // Deploy dependencies after rebuilding and before restarting. - if (!projectsToRedeploy.IsEmpty) + if (updates.ProjectsToRedeploy is not []) { - DeployProjectDependencies(evaluationResult.ProjectGraph, projectsToRedeploy, iterationCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); + await DeployProjectDependenciesAsync(evaluationResult, updates.ProjectsToRedeploy, iterationCancellationToken); + _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updates.ProjectsToRedeploy.Count); } // Apply updates only after dependencies have been deployed, // so that updated code doesn't attempt to access the dependency before it has been deployed. - if (!managedCodeUpdates.IsEmpty) + if (updates.ManagedCodeUpdates.Count > 0 || updates.StaticAssetsToUpdate.Count > 0) { - await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, stopwatch, iterationCancellationToken); + await compilationHandler.ApplyManagedCodeAndStaticAssetUpdatesAsync(updates.ManagedCodeUpdates, updates.StaticAssetsToUpdate, stopwatch, iterationCancellationToken); } - if (!projectsToRestart.IsEmpty) + if (updates.ProjectsToRestart is not []) { await Task.WhenAll( - projectsToRestart.Select(async runningProject => - { - var newRunningProject = await runningProject.RestartOperation(shutdownCancellationToken); - _ = await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); - })) + updates.ProjectsToRestart.Select(async runningProject => runningProject.RestartOperation(shutdownCancellationToken))) .WaitAsync(shutdownCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Length); + _context.Logger.Log(MessageDescriptor.ProjectsRestarted, updates.ProjectsToRestart.Count); } - async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) + async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects) { var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); if (changedPaths is []) @@ -422,7 +366,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr } // Do not assume the change is an addition, even if the file doesn't exist in the evaluation result. - // The file could have been deleted and Add + Delete sequence could have been normalized to Update. + // The file could have been deleted and Add + Delete sequence could have been normalized to Update. return new ChangedFile( new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] }, changedPath.Kind); @@ -436,12 +380,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr if (evaluationRequired) { // TODO: consider re-evaluating only affected projects instead of the whole graph. - evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken); + evaluationResult = await EvaluateProjectGraphAsync(restore: true, iterationCancellationToken); // additional files/directories may have been added: evaluationResult.WatchFiles(fileWatcher); - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -462,7 +406,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted); } - if (!rebuiltProjects.IsEmpty) + if (rebuiltProjects is not []) { // Filter changed files down to those contained in projects being rebuilt. // File changes that affect projects that are not being rebuilt will stay in the accumulator @@ -493,7 +437,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr { // Update the workspace to reflect changes in the file content:. // If the project was re-evaluated the Roslyn solution is already up to date. - await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); + await compilationHandler.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } return [.. changedFiles]; @@ -519,7 +463,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr if (runtimeProcessLauncher != null) { - // Request cleanup of all processes created by the launcher before we terminate the root process. + // Request cleanup of all processes created by the launcher before we terminate the main process. // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. await runtimeProcessLauncher.TerminateLaunchedProcessesAsync(CancellationToken.None); } @@ -527,12 +471,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr if (compilationHandler != null) { // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. - await compilationHandler.TerminateNonRootProcessesAndDispose(CancellationToken.None); + await compilationHandler.TerminatePeripheralProcessesAndDispose(CancellationToken.None); } - if (rootRunningProject != null) + if (mainRunningProject != null) { - await rootRunningProject.TerminateAsync(); + await mainRunningProject.TerminateAsync(); } if (runtimeProcessLauncher != null) @@ -543,7 +487,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr if (waitForFileChangeBeforeRestarting && !shutdownCancellationToken.IsCancellationRequested && !forceRestartCancellationSource.IsCancellationRequested && - rootRunningProject?.IsRestarting != true) + mainRunningProject?.IsRestarting != true) { using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); @@ -552,6 +496,40 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArr } } + private async Task RestartPrompt(IEnumerable projectNames, IRuntimeProcessLauncher? runtimeProcessLauncher, CancellationToken cancellationToken) + { + if (_rudeEditRestartPrompt != null) + { + string question; + if (runtimeProcessLauncher == null) + { + question = "Do you want to restart your app?"; + } + else + { + _context.Logger.LogInformation("Affected projects:"); + + foreach (var projectName in projectNames.OrderBy(n => n)) + { + _context.Logger.LogInformation(" {ProjectName}", projectName); + } + + question = "Do you want to restart these projects?"; + } + + return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken); + } + + _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode."); + + foreach (var projectName in projectNames) + { + _context.Logger.LogDebug(" Project to restart: '{ProjectName}'", projectName); + } + + return true; + } + private void AnalyzeFileChanges( List changedFiles, EvaluationResult evaluationResult, @@ -606,7 +584,7 @@ private static bool MatchesBuildFile(string filePath) return extension.Equals(".props", PathUtilities.OSSpecificPathComparison) || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison) || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison) - || extension.Equals("projitems", PathUtilities.OSSpecificPathComparison) // shared project items + || extension.Equals(".projitems", PathUtilities.OSSpecificPathComparison) // shared project items || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison); } @@ -650,70 +628,102 @@ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluation return false; } - private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray projectPaths, CancellationToken cancellationToken) + private async ValueTask DeployProjectDependenciesAsync(EvaluationResult evaluationResult, IEnumerable projectPaths, CancellationToken cancellationToken) { + const string TargetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; + var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); - var buildReporter = new BuildReporter(_context.Logger, _context.Options, _context.EnvironmentOptions); - var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; - foreach (var node in graph.ProjectNodes) + var buildRequests = new List>(); + + foreach (var (_, restoredProjectInstance) in evaluationResult.RestoredProjectInstances) { cancellationToken.ThrowIfCancellationRequested(); - var projectPath = node.ProjectInstance.FullPath; + // Avoid modification of the restored snapshot. + var projectInstance = restoredProjectInstance.DeepCopy(); + + var projectPath = projectInstance.FullPath; if (!projectPathSet.Contains(projectPath)) { continue; } - if (!node.ProjectInstance.Targets.ContainsKey(targetName)) + if (!projectInstance.Targets.ContainsKey(TargetName)) { continue; } - if (node.GetOutputDirectory() is not { } relativeOutputDir) + if (projectInstance.GetOutputDirectory() is not { } relativeOutputDir) { continue; } - using var loggers = buildReporter.GetLoggers(projectPath, targetName); - if (!node.ProjectInstance.Build([targetName], loggers, out var targetOutputs)) + buildRequests.Add(BuildRequest.Create(projectInstance, [TargetName], relativeOutputDir)); + } + + var results = await evaluationResult.BuildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => + { + _context.Logger.LogDebug("[{ProjectName}] {TargetName} target failed", failedInstance.GetDisplayName(), TargetName); + + // continue build + return true; + }, + operationName: "DeployProjectDependencies", + cancellationToken); + + var copyTasks = new List(); + + foreach (var result in results) + { + if (!result.IsSuccess) { - _context.Logger.LogDebug("{TargetName} target failed", targetName); - loggers.ReportOutput(); continue; } + var relativeOutputDir = result.Data; + var projectInstance = result.ProjectInstance; + + var projectPath = projectInstance.FullPath; + var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir); - foreach (var item in targetOutputs[targetName].Items) + foreach (var item in result.TargetResults[TargetName].Items) { cancellationToken.ThrowIfCancellationRequested(); var sourcePath = item.ItemSpec; var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath)); - if (!File.Exists(targetPath)) - { - _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath); - try + copyTasks.Add(Task.Run(() => + { + if (!File.Exists(targetPath)) { - var directory = Path.GetDirectoryName(targetPath); - if (directory != null) + _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath); + + try { - Directory.CreateDirectory(directory); - } + var directory = Path.GetDirectoryName(targetPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } - File.Copy(sourcePath, targetPath, overwrite: false); - } - catch (Exception e) - { - _context.Logger.LogDebug("Copy failed: {Message}", e.Message); + File.Copy(sourcePath, targetPath, overwrite: false); + } + catch (Exception e) + { + _context.Logger.LogDebug("Copy failed: {Message}", e.Message); + } } - } + }, cancellationToken)); } } + + await Task.WhenAll(copyTasks); } private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) @@ -732,8 +742,8 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche } else { - // evaluation cancelled - watch for any changes in the directory tree containing the root project: - fileWatcher.WatchContainingDirectories([_context.RootProjectOptions.ProjectPath], includeSubdirectories: true); + // evaluation cancelled - watch for any changes in the directory trees containing root projects or entry-point files: + fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true); _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: AcceptChange, @@ -910,33 +920,26 @@ static string GetPluralMessage(ChangeKind kind) }; } - private async ValueTask EvaluateRootProjectAsync(bool restore, CancellationToken cancellationToken) + private async ValueTask EvaluateProjectGraphAsync(bool restore, CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); - _context.Logger.LogInformation("Evaluating projects ..."); + _context.Logger.LogInformation("Loading projects ..."); var stopwatch = Stopwatch.StartNew(); - var result = EvaluationResult.TryCreate( - _designTimeBuildGraphFactory, - _context.RootProjectOptions.ProjectPath, - _context.BuildLogger, - _context.Options, - _context.EnvironmentOptions, - restore, - cancellationToken); - - _context.Logger.LogInformation("Evaluation completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + var result = await EvaluationResult.TryCreateAsync(_designTimeBuildGraphFactory, _context.Options, _context.EnvironmentOptions, restore, cancellationToken); + var timeDisplay = stopwatch.Elapsed.TotalSeconds.ToString("0.0"); if (result != null) { + _context.Logger.LogInformation("Loaded {ProjectCount} project(s) in {Time}s.", result.ProjectGraph.Graph.ProjectNodes.Count, timeDisplay); return result; } await FileWatcher.WaitForFileChangeAsync( - _context.RootProjectOptions.ProjectPath, + _context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), _context.Logger, _context.EnvironmentOptions, startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), @@ -944,43 +947,84 @@ await FileWatcher.WaitForFileChangeAsync( } } - private async Task BuildProjectAsync(string projectPath, IReadOnlyList buildArguments, CancellationToken cancellationToken) + private async Task BuildProjectsAsync(ImmutableArray projects, IReadOnlyList buildArguments, CancellationToken cancellationToken) { - List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; + Debug.Assert(projects.Any()); - var processSpec = new ProcessSpec + List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; + string? solutionFile = null; + try { - Executable = _context.EnvironmentOptions.MuxerPath, - WorkingDirectory = Path.GetDirectoryName(projectPath)!, - IsUserApplication = false, - - // Capture output if running in a test environment. - // If the output is not captured dotnet build will show live build progress. - OnOutput = capturedOutput != null - ? line => + // TODO: workaround for https://github.com/dotnet/sdk/issues/51311 + // does not work with single-file apps + if (projects is not [var project]) + { + solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx"); + + var solutionElement = new XElement("Solution"); + + foreach (var p in projects) { - lock (capturedOutput) + if (p.PhysicalPath != null) { - capturedOutput.Add(line); + solutionElement.Add(new XElement("Project", new XAttribute("Path", p.PhysicalPath))); } } + + var doc = new XDocument(solutionElement); + doc.Save(solutionFile); + + project = new ProjectRepresentation(projectPath: solutionFile, entryPointFilePath: null); + } + + var processSpec = new ProcessSpec + { + Executable = _context.EnvironmentOptions.MuxerPath, + WorkingDirectory = project.GetContainingDirectory(), + IsUserApplication = false, + + // Capture output if running in a test environment. + // If the output is not captured dotnet build will show live build progress. + OnOutput = capturedOutput != null + ? line => + { + lock (capturedOutput) + { + capturedOutput.Add(line); + } + } : null, - // pass user-specified build arguments last to override defaults: - Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. buildArguments] - }; + // pass user-specified build arguments last to override defaults: + Arguments = ["build", project.ProjectOrEntryPointFilePath, .. buildArguments] + }; - _context.BuildLogger.Log(MessageDescriptor.Building, projectPath); + _context.BuildLogger.Log(MessageDescriptor.Building, project.ProjectOrEntryPointFilePath); - var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0; + var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0; - if (capturedOutput != null) + if (capturedOutput != null) + { + _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, project.ProjectOrEntryPointFilePath); + BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success); + } + + return success; + } + finally { - _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, projectPath); - BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success); + if (solutionFile != null) + { + try + { + File.Delete(solutionFile); + } + catch + { + // ignore + } + } } - - return success; } private string GetRelativeFilePath(string path) diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs deleted file mode 100644 index 4196f85ba21b..000000000000 --- a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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.Tracing; - -namespace Microsoft.DotNet.Watch -{ - [EventSource(Name = "HotReload")] - internal sealed class HotReloadEventSource : EventSource - { - public enum StartType - { - Main, - StaticHandler, - CompilationHandler, - } - - internal sealed class Keywords - { - public const EventKeywords Perf = (EventKeywords)1; - } - - [Event(1, Message = "Hot reload started for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadStart(StartType handlerType) { WriteEvent(1, handlerType); } - - [Event(2, Message = "Hot reload finished for {0}", Level = EventLevel.Informational, Keywords = Keywords.Perf)] - public void HotReloadEnd(StartType handlerType) { WriteEvent(2, handlerType); } - - public static readonly HotReloadEventSource Log = new(); - } -} diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs b/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs new file mode 100644 index 000000000000..a56fbe8e3696 --- /dev/null +++ b/src/BuiltInTools/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs @@ -0,0 +1,17 @@ +// 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.CodeAnalysis.ExternalAccess.HotReload.Api; +using Microsoft.DotNet.HotReload; + +namespace Microsoft.DotNet.Watch; + +internal sealed class HotReloadProjectUpdatesBuilder +{ + public List ManagedCodeUpdates { get; } = []; + public Dictionary> StaticAssetsToUpdate { get; } = []; + public List ProjectsToRebuild { get; } = []; + public List ProjectsToRedeploy { get; } = []; + public List ProjectsToRestart { get; } = []; +} diff --git a/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs b/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs deleted file mode 100644 index 1581a57f3c66..000000000000 --- a/src/BuiltInTools/Watch/HotReload/IncrementalMSBuildWorkspace.cs +++ /dev/null @@ -1,282 +0,0 @@ -// 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.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; -using Microsoft.CodeAnalysis.Host.Mef; -using Microsoft.CodeAnalysis.MSBuild; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch; - -internal sealed class IncrementalMSBuildWorkspace : Workspace -{ - private readonly ILogger _logger; - private int _solutionUpdateId; - - public IncrementalMSBuildWorkspace(ILogger logger) - : base(MSBuildMefHostServices.DefaultServices, WorkspaceKind.MSBuild) - { -#pragma warning disable CS0618 // https://github.com/dotnet/sdk/issues/49725 - WorkspaceFailed += (_sender, diag) => - { - // Report both Warning and Failure as warnings. - // MSBuildProjectLoader reports Failures for cases where we can safely continue loading projects - // (e.g. non-C#/VB project is ignored). - // https://github.com/dotnet/roslyn/issues/75170 - logger.LogWarning($"msbuild: {diag.Diagnostic}"); - }; -#pragma warning restore CS0618 - - _logger = logger; - } - - public async Task UpdateProjectConeAsync(string rootProjectPath, CancellationToken cancellationToken) - { - _logger.LogInformation("Loading projects ..."); - - var stopwatch = Stopwatch.StartNew(); - var oldSolution = CurrentSolution; - - var loader = new MSBuildProjectLoader(this); - var projectMap = ProjectMap.Create(); - - ImmutableArray projectInfos; - try - { - projectInfos = await loader.LoadProjectInfoAsync(rootProjectPath, projectMap, progress: null, msbuildLogger: null, cancellationToken).ConfigureAwait(false); - } - catch (InvalidOperationException) - { - // TODO: workaround for https://github.com/dotnet/roslyn/issues/75956 - projectInfos = []; - } - - var oldProjectIdsByPath = oldSolution.Projects.ToDictionary(keySelector: static p => (p.FilePath!, p.Name), elementSelector: static p => p.Id); - - // Map new project id to the corresponding old one based on file path and project name (includes TFM), if it exists, and null for added projects. - // Deleted projects won't be included in this map. - var projectIdMap = projectInfos.ToDictionary( - keySelector: static info => info.Id, - elementSelector: info => oldProjectIdsByPath.TryGetValue((info.FilePath!, info.Name), out var oldProjectId) ? oldProjectId : null); - - var newSolution = oldSolution; - - foreach (var newProjectInfo in projectInfos) - { - Debug.Assert(newProjectInfo.FilePath != null); - - var oldProjectId = projectIdMap[newProjectInfo.Id]; - if (oldProjectId == null) - { - newSolution = newSolution.AddProject(newProjectInfo); - continue; - } - - newSolution = HotReloadService.WithProjectInfo(newSolution, WithChecksumAlgorithm(ProjectInfo.Create( - oldProjectId, - newProjectInfo.Version, - newProjectInfo.Name, - newProjectInfo.AssemblyName, - newProjectInfo.Language, - newProjectInfo.FilePath, - newProjectInfo.OutputFilePath, - newProjectInfo.CompilationOptions, - newProjectInfo.ParseOptions, - MapDocuments(oldProjectId, newProjectInfo.Documents), - newProjectInfo.ProjectReferences.Select(MapProjectReference), - newProjectInfo.MetadataReferences, - newProjectInfo.AnalyzerReferences, - MapDocuments(oldProjectId, newProjectInfo.AdditionalDocuments), - isSubmission: false, - hostObjectType: null, - outputRefFilePath: newProjectInfo.OutputRefFilePath), - GetChecksumAlgorithm(newProjectInfo)) - .WithAnalyzerConfigDocuments(MapDocuments(oldProjectId, newProjectInfo.AnalyzerConfigDocuments)) - .WithCompilationOutputInfo(newProjectInfo.CompilationOutputInfo)); - } - - await UpdateSolutionAsync(newSolution, operationDisplayName: "project update", cancellationToken); - UpdateReferencesAfterAdd(); - - _logger.LogInformation("Projects loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); - - ProjectReference MapProjectReference(ProjectReference pr) - // Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing. - // When a new project is added along with a new project reference the old project id is also null. - => new(projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes); - - ImmutableArray MapDocuments(ProjectId mappedProjectId, IReadOnlyList documents) - => documents.Select(docInfo => - { - // TODO: can there be multiple documents of the same path in the project? - - // Map to a document of the same path. If there isn't one (a new document is added to the project), - // create a new document id with the mapped project id. - var mappedDocumentId = oldSolution.GetDocumentIdsWithFilePath(docInfo.FilePath).FirstOrDefault(id => id.ProjectId == mappedProjectId) - ?? DocumentId.CreateNewId(mappedProjectId); - - return docInfo.WithId(mappedDocumentId); - }).ToImmutableArray(); - } - - // TODO: remove - // workaround for https://github.com/dotnet/roslyn/pull/82051 - - private static MethodInfo? s_withChecksumAlgorithm; - private static PropertyInfo? s_getChecksumAlgorithm; - - private static ProjectInfo WithChecksumAlgorithm(ProjectInfo info, SourceHashAlgorithm algorithm) - => (ProjectInfo)(s_withChecksumAlgorithm ??= typeof(ProjectInfo).GetMethod("WithChecksumAlgorithm", BindingFlags.NonPublic | BindingFlags.Instance)!) - .Invoke(info, [algorithm])!; - - private static SourceHashAlgorithm GetChecksumAlgorithm(ProjectInfo info) - => (SourceHashAlgorithm)(s_getChecksumAlgorithm ??= typeof(ProjectInfo).GetProperty("ChecksumAlgorithm", BindingFlags.NonPublic | BindingFlags.Instance)!) - .GetValue(info)!; - - public async ValueTask UpdateFileContentAsync(IEnumerable changedFiles, CancellationToken cancellationToken) - { - var updatedSolution = CurrentSolution; - - var documentsToRemove = new List(); - - foreach (var (changedFile, change) in changedFiles) - { - // when a file is added we reevaluate the project: - Debug.Assert(change != ChangeKind.Add); - - var documentIds = updatedSolution.GetDocumentIdsWithFilePath(changedFile.FilePath); - if (change == ChangeKind.Delete) - { - documentsToRemove.AddRange(documentIds); - continue; - } - - foreach (var documentId in documentIds) - { - var textDocument = updatedSolution.GetDocument(documentId) - ?? updatedSolution.GetAdditionalDocument(documentId) - ?? updatedSolution.GetAnalyzerConfigDocument(documentId); - - if (textDocument == null) - { - _logger.LogDebug("Could not find document with path '{FilePath}' in the workspace.", changedFile.FilePath); - continue; - } - - var project = updatedSolution.GetProject(documentId.ProjectId); - Debug.Assert(project?.FilePath != null); - - var oldText = await textDocument.GetTextAsync(cancellationToken); - Debug.Assert(oldText.Encoding != null); - - var newText = await GetSourceTextAsync(changedFile.FilePath, oldText.Encoding, oldText.ChecksumAlgorithm, cancellationToken); - - _logger.LogDebug("Updating document text of '{FilePath}'.", changedFile.FilePath); - - updatedSolution = textDocument switch - { - Document document => document.WithText(newText).Project.Solution, - AdditionalDocument ad => updatedSolution.WithAdditionalDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue), - AnalyzerConfigDocument acd => updatedSolution.WithAnalyzerConfigDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue), - _ => throw new InvalidOperationException() - }; - } - } - - updatedSolution = RemoveDocuments(updatedSolution, documentsToRemove); - - await UpdateSolutionAsync(updatedSolution, operationDisplayName: "document update", cancellationToken); - } - - private static Solution RemoveDocuments(Solution solution, IEnumerable ids) - => solution - .RemoveDocuments([.. ids.Where(id => solution.GetDocument(id) != null)]) - .RemoveAdditionalDocuments([.. ids.Where(id => solution.GetAdditionalDocument(id) != null)]) - .RemoveAnalyzerConfigDocuments([.. ids.Where(id => solution.GetAnalyzerConfigDocument(id) != null)]); - - private static async ValueTask GetSourceTextAsync(string filePath, Encoding encoding, SourceHashAlgorithm checksumAlgorithm, CancellationToken cancellationToken) - { - var zeroLengthRetryPerformed = false; - for (var attemptIndex = 0; attemptIndex < 6; attemptIndex++) - { - try - { - // File.OpenRead opens the file with FileShare.Read. This may prevent IDEs from saving file - // contents to disk - SourceText sourceText; - using (var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - { - sourceText = SourceText.From(stream, encoding, checksumAlgorithm); - } - - if (!zeroLengthRetryPerformed && sourceText.Length == 0) - { - zeroLengthRetryPerformed = true; - - // VSCode (on Windows) will sometimes perform two separate writes when updating a file on disk. - // In the first update, it clears the file contents, and in the second, it writes the intended - // content. - // It's atypical that a file being watched for hot reload would be empty. We'll use this as a - // hueristic to identify this case and perform an additional retry reading the file after a delay. - await Task.Delay(20, cancellationToken); - - using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - sourceText = SourceText.From(stream, encoding, checksumAlgorithm); - } - - return sourceText; - } - catch (IOException) when (attemptIndex < 5) - { - await Task.Delay(20 * (attemptIndex + 1), cancellationToken); - } - } - - Debug.Fail("This shouldn't happen."); - return null; - } - - private Task UpdateSolutionAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken) - => ReportSolutionFilesAsync(SetCurrentSolution(newSolution), Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken); - - private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken) - { - _logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId); - - if (!_logger.IsEnabled(LogLevel.Trace)) - { - return; - } - - foreach (var project in solution.Projects) - { - _logger.LogDebug(" Project: {Path}", project.FilePath); - - foreach (var document in project.Documents) - { - await InspectDocumentAsync(document, "Document"); - } - - foreach (var document in project.AdditionalDocuments) - { - await InspectDocumentAsync(document, "Additional"); - } - - foreach (var document in project.AnalyzerConfigDocuments) - { - await InspectDocumentAsync(document, "Config"); - } - } - - async ValueTask InspectDocumentAsync(TextDocument document, string kind) - { - var text = await document.GetTextAsync(cancellationToken); - _logger.LogDebug(" {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray())); - } - } -} diff --git a/src/BuiltInTools/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/BuiltInTools/Watch/Microsoft.DotNet.HotReload.Watch.csproj index f3a2874dd3ea..c97882eadfd5 100644 --- a/src/BuiltInTools/Watch/Microsoft.DotNet.HotReload.Watch.csproj +++ b/src/BuiltInTools/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -30,7 +30,6 @@ - diff --git a/src/BuiltInTools/Watch/Process/IRuntimeProcessLauncherFactory.cs b/src/BuiltInTools/Watch/Process/IRuntimeProcessLauncherFactory.cs index 93d69f69db59..8859bcbe9794 100644 --- a/src/BuiltInTools/Watch/Process/IRuntimeProcessLauncherFactory.cs +++ b/src/BuiltInTools/Watch/Process/IRuntimeProcessLauncherFactory.cs @@ -12,5 +12,5 @@ namespace Microsoft.DotNet.Watch; /// internal interface IRuntimeProcessLauncherFactory { - public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions); + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher); } diff --git a/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs b/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs index 1e66687c5690..08b30510b586 100644 --- a/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs +++ b/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs @@ -23,9 +23,9 @@ internal sealed class LaunchSettingsProfile public bool LaunchBrowser { get; init; } public string? LaunchUrl { get; init; } - internal static LaunchSettingsProfile? ReadLaunchProfile(string projectPath, string? launchProfileName, ILogger logger) + internal static LaunchSettingsProfile? ReadLaunchProfile(ProjectRepresentation project, string? launchProfileName, ILogger logger) { - var launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(projectPath, launchProfileName, (message, isError) => + var launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(project.ProjectOrEntryPointFilePath, launchProfileName, (message, isError) => { if (isError) { diff --git a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs index fb74333ebdd3..0eebabd49ca2 100644 --- a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -11,7 +12,7 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectLauncher( DotNetWatchContext context, - ProjectNodeMap projectMap, + LoadedProjectGraph projectGraph, CompilationHandler compilationHandler, int iteration) { @@ -34,32 +35,20 @@ public EnvironmentOptions EnvironmentOptions RestartOperation restartOperation, CancellationToken cancellationToken) { - var projectNode = projectMap.TryGetProjectNode(projectOptions.ProjectPath, projectOptions.TargetFramework); + var projectNode = projectGraph.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, context.TargetFramework); if (projectNode == null) { // error already reported return null; } - if (!projectNode.IsNetCoreApp(Versions.Version6_0)) - { - Logger.LogError($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); - return null; - } - - var appModel = HotReloadAppModel.InferFromProject(context, projectNode); - // create loggers that include project name in messages: var projectDisplayName = projectNode.GetDisplayName(); var clientLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); var agentLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); - var clients = await appModel.TryCreateClientsAsync(clientLogger, agentLogger, cancellationToken); - if (clients == null) - { - // error already reported - return null; - } + var appModel = HotReloadAppModel.InferFromProject(context, projectNode); + var clients = await appModel.CreateClientsAsync(clientLogger, agentLogger, cancellationToken); var processSpec = new ProcessSpec { @@ -91,7 +80,7 @@ public EnvironmentOptions EnvironmentOptions environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1"; environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture); - if (Logger.IsEnabled(LogLevel.Trace)) + if (clients.IsManagedAgentSupported && Logger.IsEnabled(LogLevel.Trace)) { environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; @@ -109,6 +98,7 @@ public EnvironmentOptions EnvironmentOptions projectNode, projectOptions, clients, + clientLogger, processSpec, restartOperation, processTerminationSource, diff --git a/src/BuiltInTools/Watch/Process/RunningProject.cs b/src/BuiltInTools/Watch/Process/RunningProject.cs index c4d63e953a22..748f8f3c7ef3 100644 --- a/src/BuiltInTools/Watch/Process/RunningProject.cs +++ b/src/BuiltInTools/Watch/Process/RunningProject.cs @@ -15,17 +15,19 @@ internal sealed class RunningProject( ProjectGraphNode projectNode, ProjectOptions options, HotReloadClients clients, + ILogger clientLogger, Task runningProcess, int processId, CancellationTokenSource processExitedSource, CancellationTokenSource processTerminationSource, RestartOperation restartOperation, - ImmutableArray capabilities) : IDisposable + ImmutableArray managedCodeUpdateCapabilities) : IDisposable { public readonly ProjectGraphNode ProjectNode = projectNode; public readonly ProjectOptions Options = options; public readonly HotReloadClients Clients = clients; - public readonly ImmutableArray Capabilities = capabilities; + public readonly ILogger ClientLogger = clientLogger; + public readonly ImmutableArray ManagedCodeUpdateCapabilities = managedCodeUpdateCapabilities; public readonly Task RunningProcess = runningProcess; public readonly int ProcessId = processId; public readonly RestartOperation RestartOperation = restartOperation; @@ -61,26 +63,6 @@ public void Dispose() processExitedSource.Dispose(); } - /// - /// Waits for the application process to start. - /// Ensures that the build has been complete and the build outputs are available. - /// Returns false if the process has exited before the connection was established. - /// - public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) - { - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ProcessExitedCancellationToken); - - try - { - await Clients.WaitForConnectionEstablishedAsync(processCommunicationCancellationSource.Token); - return true; - } - catch (OperationCanceledException) when (ProcessExitedCancellationToken.IsCancellationRequested) - { - return false; - } - } - /// /// Terminates the process if it hasn't terminated yet. /// @@ -126,7 +108,7 @@ public async Task CompleteApplyOperationAsync(Task applyTask) // Handle all exceptions. If one process is terminated or fails to apply changes // it shouldn't prevent applying updates to other processes. - Clients.ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); + ClientLogger.LogError("Failed to apply updates to process {Process}: {Exception}", ProcessId, e.ToString()); } } } diff --git a/src/BuiltInTools/Watch/UI/IReporter.cs b/src/BuiltInTools/Watch/UI/IReporter.cs index b2421ef89fa4..f73fcb349e6b 100644 --- a/src/BuiltInTools/Watch/UI/IReporter.cs +++ b/src/BuiltInTools/Watch/UI/IReporter.cs @@ -135,20 +135,6 @@ public static MessageDescriptor GetDescriptor(EventId id) public string GetMessage(params object?[] args) => Id.Id == 0 ? Format : string.Format(Format, args); - public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) - => condition && Level != level - ? this with - { - Level = level, - Emoji = level switch - { - LogLevel.Error or LogLevel.Critical => Emoji.Error, - LogLevel.Warning => Emoji.Warning, - _ => Emoji - } - } - : this; - public static readonly ImmutableDictionary ComponentEmojis = ImmutableDictionary.Empty .Add(DotNetWatchContext.DefaultLogComponentName, Emoji.Watch) .Add(DotNetWatchContext.BuildLogComponentName, Emoji.Build) @@ -189,8 +175,10 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ManualRefreshRequired = Create("Browser refresh is suppressed via environment variable '{0}'. To reload static assets after an update refresh browser manually.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor BrowserRefreshSuppressedViaEnvironmentVariable_ApplicationWillBeRestarted = Create("Browser refresh is suppressed via environment variable '{0}'. Application will be restarted when updated.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ManualRefreshRequired = Create("Browser refresh is ot supported by the project target framework. To reload static assets after an update refresh browser manually. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); + public static readonly MessageDescriptor BrowserRefreshNotSupportedByProjectTargetFramework_ApplicationWillBeRestarted = Create("Browser refresh is ot supported by the project target framework. Application will be restarted when updated. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, LogLevel.Warning); public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); @@ -207,7 +195,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# or Razor changes to apply.", Emoji.Watch, LogLevel.Information); + public static readonly MessageDescriptor NoManagedCodeChangesToApply = Create("No managed code changes to apply.", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, LogLevel.Error); @@ -226,6 +214,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor RestartNeededToApplyChanges = Create("Restart is needed to apply the changes.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor ProjectDoesNotSupportHotReload = Create("Project does not support Hot Reload: {0}. Application will be restarted when updated.", Emoji.Warning, LogLevel.Warning); public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, LogLevel.Information); public static readonly MessageDescriptor ApplicationKind_BlazorHosted = Create("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, LogLevel.Debug); diff --git a/src/BuiltInTools/Watch/Utilities/Option.cs b/src/BuiltInTools/Watch/Utilities/Option.cs new file mode 100644 index 000000000000..3caf60e263bd --- /dev/null +++ b/src/BuiltInTools/Watch/Utilities/Option.cs @@ -0,0 +1,18 @@ +// 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 readonly struct Optional(T value) +{ + public bool HasValue { get; } = true; + public T Value => value; + + public static implicit operator Optional(T value) + => new(value); + + public override string ToString() + => HasValue + ? Value?.ToString() ?? "null" + : "unspecified"; +} diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs index 7335284574b0..3d7a6ff42959 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs @@ -22,10 +22,10 @@ internal sealed class CommandLineOptions public bool List { get; init; } public required GlobalOptions GlobalOptions { get; init; } + public string? FilePath { get; init; } public string? ProjectPath { get; init; } public string? TargetFramework { get; init; } - public bool NoLaunchProfile { get; init; } - public string? LaunchProfileName { get; init; } + public Optional LaunchProfileName { get; init; } /// /// Arguments passed to . @@ -145,8 +145,8 @@ internal sealed class CommandLineOptions IsExplicitCommand = isExplicitCommand, ProjectPath = projectValue, - LaunchProfileName = parseResult.GetValue(definition.LaunchProfileOption), - NoLaunchProfile = parseResult.GetValue(definition.NoLaunchProfileOption), + FilePath = parseResult.GetValue(definition.FileOption), + LaunchProfileName = parseResult.GetValue(definition.NoLaunchProfileOption) ? default : parseResult.GetValue(definition.LaunchProfileOption), BuildArguments = buildArguments, TargetFramework = targetFrameworkOption != null ? parseResult.GetValue(targetFrameworkOption) : null, }; @@ -343,18 +343,15 @@ private static int IndexOf(IReadOnlyList list, Func predicate) return -1; } - public ProjectOptions GetProjectOptions(string projectPath, string workingDirectory) + public ProjectOptions GetMainProjectOptions(ProjectRepresentation project, string workingDirectory) => new() { - IsRootProject = true, - ProjectPath = projectPath, + IsMainProject = true, + Representation = project, WorkingDirectory = workingDirectory, Command = Command, CommandArguments = CommandArguments, LaunchEnvironmentVariables = [], LaunchProfileName = LaunchProfileName, - NoLaunchProfile = NoLaunchProfile, - BuildArguments = BuildArguments, - TargetFramework = TargetFramework, }; } diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs b/src/BuiltInTools/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs index 48d694d3cffa..8405d047018d 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Watch; @@ -17,6 +18,7 @@ internal sealed class DotnetWatchCommandDefinition : RootCommand // Options we need to know about. They are passed through to the subcommand if the subcommand supports them. public readonly Option ShortProjectOption = new("-p") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; public readonly Option LongProjectOption = new("--project") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; + public readonly Option FileOption = new("--file") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Hidden = true, Arity = ArgumentArity.Zero }; @@ -38,14 +40,31 @@ public DotnetWatchCommandDefinition() Options.Add(LongProjectOption); Options.Add(ShortProjectOption); + Options.Add(FileOption); Options.Add(LaunchProfileOption); Options.Add(NoLaunchProfileOption); VerboseOption.Validators.Add(v => { - if (v.GetValue(QuietOption) && v.GetValue(VerboseOption)) + if (v.HasOption(QuietOption) && v.HasOption(VerboseOption)) { - v.AddError(Resources.Error_QuietAndVerboseSpecified); + v.AddError(string.Format(Resources.Cannot_specify_both_0_and_1_options, QuietOption.Name, VerboseOption.Name)); + } + + var hasLongProjectOption = v.HasOption(LongProjectOption); + var hasShortProjectOption = v.HasOption(ShortProjectOption); + + if (hasLongProjectOption && hasShortProjectOption) + { + v.AddError(string.Format(Resources.Cannot_specify_both_0_and_1_options, LongProjectOption.Name, ShortProjectOption.Name)); + } + + if (v.HasOption(FileOption) && (hasLongProjectOption || hasShortProjectOption)) + { + v.AddError(string.Format( + Resources.Cannot_specify_both_0_and_1_options, + FileOption.Name, + hasLongProjectOption ? LongProjectOption.Name : ShortProjectOption.Name)); } }); } diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 938865c9d367..86bdb0fa2a91 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -3,9 +3,10 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Runtime.Loader; using Microsoft.Build.Locator; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.ProjectTools; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -15,7 +16,7 @@ internal sealed class Program( ILoggerFactory loggerFactory, ILogger logger, IProcessOutputReporter processOutputReporter, - ProjectOptions rootProjectOptions, + ProjectOptions mainProjectOptions, CommandLineOptions options, EnvironmentOptions environmentOptions) { @@ -43,7 +44,7 @@ public static async Task Main(string[] args) var processPath = Environment.ProcessPath; Debug.Assert(processPath != null); - + var environmentOptions = EnvironmentOptions.FromEnvironment(processPath); // msbuild tasks depend on host path variable: @@ -99,36 +100,110 @@ public static async Task Main(string[] args) logger.LogDebug("Test flags: {Flags}", environmentOptions.TestFlags); } - if (!TryFindProject(workingDirectory, options, logger, out var projectPath)) + var mainProjectOptions = GetMainProjectOptions(options, workingDirectory, logger); + if (mainProjectOptions == null) { errorCode = 1; return null; } - var rootProjectOptions = options.GetProjectOptions(projectPath, workingDirectory); errorCode = 0; - return new Program(console, loggerFactory, logger, processOutputReporter, rootProjectOptions, options, environmentOptions); + return new Program(console, loggerFactory, logger, processOutputReporter, mainProjectOptions, options, environmentOptions); + } + + // internal for testing + internal static ProjectOptions? GetMainProjectOptions(CommandLineOptions options, string workingDirectory, ILogger logger) + { + ProjectRepresentation project; + + if (options.FilePath != null) + { + try + { + project = new ProjectRepresentation(projectPath: null, entryPointFilePath: Path.GetFullPath(Path.Combine(workingDirectory, options.FilePath))); + } + catch (Exception e) + { + logger.LogError(Resources.The_specified_path_0_is_invalid_1, options.FilePath, e.Message); + return null; + } + } + else if (TryFindProject(workingDirectory, options, logger, out var projectPath) is { } foundProject) + { + if (foundProject == false) + { + // error already reported + return null; + } + + project = new ProjectRepresentation(projectPath, entryPointFilePath: null); + } + else if (TryFindFileEntryPoint(workingDirectory, options, logger, out var entryPointFilePath)) + { + project = new ProjectRepresentation(projectPath: null, entryPointFilePath); + } + else + { + logger.LogError(Resources.Could_not_find_msbuild_project_file_in_0, projectPath); + return null; + } + + return options.GetMainProjectOptions(project, workingDirectory); + } + + private static bool TryFindFileEntryPoint(string workingDirectory, CommandLineOptions options, ILogger logger, [NotNullWhen(true)] out string? entryPointPath) + { + if (options.CommandArguments is not [var firstArg, ..]) + { + entryPointPath = null; + return false; + } + + try + { + entryPointPath = Path.GetFullPath(Path.Combine(workingDirectory, firstArg)); + } + catch + { + entryPointPath = null; + return false; + } + + return VirtualProjectBuilder.IsValidEntryPointPath(entryPointPath); } /// /// Finds a compatible MSBuild project. - /// The base directory to search + /// The base directory to search /// The filename of the project. Can be null. /// - private static bool TryFindProject(string searchBase, CommandLineOptions options, ILogger logger, [NotNullWhen(true)] out string? projectPath) + private static bool? TryFindProject(string workingDirectory, CommandLineOptions options, ILogger logger, [NotNullWhen(true)] out string? projectPath) { - projectPath = options.ProjectPath ?? searchBase; + projectPath = options.ProjectPath ?? workingDirectory; - if (!Path.IsPathRooted(projectPath)) + try { - projectPath = Path.Combine(searchBase, projectPath); + projectPath = Path.GetFullPath(Path.Combine(workingDirectory, projectPath)); + } + catch (Exception e) + { + logger.LogError(Resources.The_specified_path_0_is_invalid_1, projectPath, e.Message); + return false; } if (Directory.Exists(projectPath)) { - var projects = Directory.EnumerateFileSystemEntries(projectPath, "*.*proj", SearchOption.TopDirectoryOnly) - .Where(f => !".xproj".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase)) - .ToList(); + List projects; + try + { + projects = [.. Directory.GetFiles(projectPath, "*.*proj") + .Where(p => !PathUtilities.OSSpecificPathComparer.Equals(Path.GetExtension(p), ".shproj"))]; + } + catch (Exception e) + { + logger.LogError(Resources.The_specified_path_0_is_invalid_1, projectPath, e.Message); + return false; + } if (projects.Count > 1) { @@ -138,14 +213,21 @@ private static bool TryFindProject(string searchBase, CommandLineOptions options if (projects.Count == 0) { - logger.LogError(Resources.Error_NoProjectsFound, projectPath); - return false; + if (options.ProjectPath != null) + { + logger.LogError(Resources.Could_not_find_msbuild_project_file_in_0, projectPath); + return false; + } + + return null; } projectPath = projects[0]; return true; } + Debug.Assert(options.ProjectPath != null); + if (!File.Exists(projectPath)) { logger.LogError(Resources.Error_ProjectPath_NotFound, projectPath); @@ -171,6 +253,12 @@ internal async Task RunAsync() if (options.List) { + if (mainProjectOptions.Representation.EntryPointFilePath != null) + { + logger.LogError("--list does not support file-based programs"); + return 1; + } + return await ListFilesAsync(processRunner, shutdownHandler.CancellationToken); } @@ -186,6 +274,11 @@ internal async Task RunAsync() var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); await watcher.WatchAsync(shutdownHandler.CancellationToken); } + else if (mainProjectOptions.Representation.EntryPointFilePath != null) + { + logger.LogError("File-based programs are only supported when Hot Reload is enabled"); + return 1; + } else { await DotNetWatcher.WatchAsync(context, shutdownHandler.CancellationToken); @@ -219,7 +312,10 @@ internal DotNetWatchContext CreateContext(ProcessRunner processRunner) ProcessRunner = processRunner, Options = options.GlobalOptions, EnvironmentOptions = environmentOptions, - RootProjectOptions = rootProjectOptions, + MainProjectOptions = mainProjectOptions, + RootProjects = [mainProjectOptions.Representation], + BuildArguments = options.BuildArguments, + TargetFramework = options.TargetFramework, BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), BrowserLauncher = new BrowserLauncher(logger, processOutputReporter, environmentOptions), }; @@ -227,9 +323,9 @@ internal DotNetWatchContext CreateContext(ProcessRunner processRunner) private bool IsHotReloadEnabled() { - if (rootProjectOptions.Command != "run") + if (mainProjectOptions.Command != "run") { - logger.LogDebug("Command '{Command}' does not support Hot Reload.", rootProjectOptions.Command); + logger.LogDebug("Command '{Command}' does not support Hot Reload.", mainProjectOptions.Command); return false; } @@ -245,13 +341,18 @@ private bool IsHotReloadEnabled() private async Task ListFilesAsync(ProcessRunner processRunner, CancellationToken cancellationToken) { + // file-based programs are not supported with --list + Debug.Assert(mainProjectOptions.Representation.PhysicalPath != null); + var buildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName); var fileSetFactory = new MSBuildFileSetFactory( - rootProjectOptions.ProjectPath, - rootProjectOptions.BuildArguments, + mainProjectOptions.Representation.PhysicalPath, + options.TargetFramework, + options.BuildArguments, processRunner, - new BuildReporter(buildLogger, options.GlobalOptions, environmentOptions)); + buildLogger, + environmentOptions); if (await fileSetFactory.TryCreateAsync(requireProjectGraph: null, cancellationToken) is not { } evaluationResult) { diff --git a/src/BuiltInTools/dotnet-watch/Resources.resx b/src/BuiltInTools/dotnet-watch/Resources.resx index 5688add72027..89103943c106 100644 --- a/src/BuiltInTools/dotnet-watch/Resources.resx +++ b/src/BuiltInTools/dotnet-watch/Resources.resx @@ -120,17 +120,19 @@ The project file '{0}' does not exist. + + The specified path '{0}' is invalid: {1} + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - {Locked="--project"} + {Locked="MSBuild"}{Locked="--project"} - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - {Locked="--project"} + {Locked="MSBuild"}{Locked="--project"} - - Cannot specify both '--quiet' and '--verbose' options. - {Locked="--quiet"}{Locked="--verbose"} + + Cannot specify both '{0}' and '{1}' options. Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. diff --git a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs index 756abbda6bc4..66497ece3813 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs @@ -31,22 +31,34 @@ internal class BuildEvaluator public BuildEvaluator(DotNetWatchContext context) { + Debug.Assert(context.MainProjectOptions != null); + _context = context; _fileSetFactory = CreateMSBuildFileSetFactory(); } + private ProjectOptions RootProjectOptions + => _context.MainProjectOptions ?? throw new InvalidOperationException(); + protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() - => new( - _context.RootProjectOptions.ProjectPath, - _context.RootProjectOptions.BuildArguments, + { + // file-based programs only supported in Hot Reload mode: + Debug.Assert(RootProjectOptions.Representation.PhysicalPath != null); + + return new( + RootProjectOptions.Representation.PhysicalPath, + _context.TargetFramework, + _context.BuildArguments, _context.ProcessRunner, - new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions)); + _context.BuildLogger, + _context.EnvironmentOptions); + } public IReadOnlyList GetProcessArguments(int iteration) { if (!_context.EnvironmentOptions.SuppressMSBuildIncrementalism && iteration > 0 && - _context.RootProjectOptions.IsCodeExecutionCommand) + _context.MainProjectOptions!.IsCodeExecutionCommand) { if (RequiresRevaluation) { @@ -55,11 +67,11 @@ public IReadOnlyList GetProcessArguments(int iteration) else { _context.Logger.LogDebug("Modifying command to use --no-restore"); - return [_context.RootProjectOptions.Command, "--no-restore", .. _context.RootProjectOptions.CommandArguments]; + return [_context.MainProjectOptions.Command, "--no-restore", .. _context.MainProjectOptions.CommandArguments]; } } - return [_context.RootProjectOptions.Command, .. _context.RootProjectOptions.CommandArguments]; + return [RootProjectOptions.Command, "--no-restore", .. RootProjectOptions.CommandArguments]; } public async ValueTask EvaluateAsync(ChangedFile? changedFile, CancellationToken cancellationToken) @@ -101,7 +113,7 @@ public IReadOnlyList GetProcessArguments(int iteration) } await FileWatcher.WaitForFileChangeAsync( - _fileSetFactory.RootProjectFile, + [_fileSetFactory.RootProjectFile], _context.Logger, _context.EnvironmentOptions, startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index b6fb10c29286..62c6f6e894fe 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -38,9 +38,8 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ProjectGraphNode? projectRootNode; if (evaluationResult.ProjectGraph != null) { - projectRootNode = evaluationResult.ProjectGraph.GraphRoots.Single(); - var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, context.Logger); - staticFileHandler = new StaticFileHandler(context.Logger, projectMap, context.BrowserRefreshServerFactory); + projectRootNode = evaluationResult.ProjectGraph.Graph.GraphRoots.Single(); + staticFileHandler = new StaticFileHandler(context.Logger, evaluationResult.ProjectGraph, context.BrowserRefreshServerFactory); } else { @@ -75,7 +74,8 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke if (projectRootNode != null) { - context.BrowserLauncher.InstallBrowserLaunchTrigger(processSpec, projectRootNode, context.RootProjectOptions, browserRefreshServer, shutdownCancellationToken); + Debug.Assert(context.MainProjectOptions != null); + context.BrowserLauncher.InstallBrowserLaunchTrigger(processSpec, projectRootNode, context.MainProjectOptions, browserRefreshServer, shutdownCancellationToken); } // Reset for next run diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 52f7f6d8985b..5a023d529d73 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -20,24 +20,27 @@ namespace Microsoft.DotNet.Watch /// internal class MSBuildFileSetFactory( string rootProjectFile, + string? targetFramework, IEnumerable buildArguments, ProcessRunner processRunner, - BuildReporter buildReporter) + ILogger logger, + EnvironmentOptions environmentOptions) { 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; private readonly ProjectGraphFactory _buildGraphFactory = new( - globalOptions: BuildUtilities.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)); + [new ProjectRepresentation(rootProjectFile, entryPointFilePath: null)], + targetFramework, + buildProperties: BuildUtilities.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value), + logger); - internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) + internal sealed class EvaluationResult(IReadOnlyDictionary files, LoadedProjectGraph? projectGraph) { public readonly IReadOnlyDictionary Files = files; - public readonly ProjectGraph? ProjectGraph = projectGraph; + public readonly LoadedProjectGraph? ProjectGraph = projectGraph; } // Virtual for testing. @@ -52,7 +55,7 @@ internal sealed class EvaluationResult(IReadOnlyDictionary fil var processSpec = new ProcessSpec { - Executable = EnvironmentOptions.MuxerPath, + Executable = environmentOptions.MuxerPath, WorkingDirectory = projectDir, IsUserApplication = false, Arguments = arguments, @@ -65,19 +68,19 @@ internal sealed class EvaluationResult(IReadOnlyDictionary fil } }; - Logger.LogDebug("Running MSBuild target '{TargetName}' on '{Path}'", TargetName, rootProjectFile); + logger.LogDebug("Running MSBuild target '{TargetName}' on '{Path}'", TargetName, rootProjectFile); - var exitCode = await processRunner.RunAsync(processSpec, Logger, launchResult: null, cancellationToken); + 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); + 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); + BuildOutput.ReportBuildOutput(logger, capturedOutput, success); if (!success) { return null; @@ -120,16 +123,16 @@ void AddFile(string filePath, string? staticWebAssetPath) } } - buildReporter.ReportWatchedFiles(fileItems); + BuildReporter.ReportWatchedFiles(logger, 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; + LoadedProjectGraph? projectGraph = null; if (requireProjectGraph != null) { - projectGraph = _buildGraphFactory.TryLoadProjectGraph(rootProjectFile, Logger, requireProjectGraph.Value, cancellationToken); + projectGraph = _buildGraphFactory.TryLoadProjectGraph(requireProjectGraph.Value, cancellationToken); if (projectGraph == null && requireProjectGraph == true) { return null; @@ -163,7 +166,7 @@ private IReadOnlyList GetMSBuildArguments(string watchListFilePath) // Set dotnet-watch reserved properties after the user specified propeties, // so that the former take precedence. - if (EnvironmentOptions.SuppressHandlingStaticWebAssets) + if (environmentOptions.SuppressHandlingStaticWebAssets) { arguments.Add("/p:DotNetWatchContentFiles=false"); } diff --git a/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs index 18d51c043277..38335764b3af 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class StaticFileHandler(ILogger logger, ProjectNodeMap projectMap, BrowserRefreshServerFactory browserConnector) + internal sealed class StaticFileHandler(ILogger logger, LoadedProjectGraph projectGraph, BrowserRefreshServerFactory browserConnector) { public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken) { @@ -31,7 +31,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f foreach (var containingProjectPath in file.ContainingProjectPaths) { - if (!projectMap.Map.TryGetValue(containingProjectPath, out var projectNodes)) + if (!projectGraph.Map.TryGetValue(containingProjectPath, out var projectNodes)) { // Shouldn't happen. logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf index 1d99d3ff575c..7aa5e7b4b623 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - {0} obsahuje více souborů projektů MSBuild. Pomocí parametru --project zadejte, který soubor chcete použít. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - V {0} se nepovedlo najít soubor projektu MSBuild. Pomocí parametru --project zadejte, který projekt chcete použít. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + {0} obsahuje více souborů projektů MSBuild. Pomocí parametru --project zadejte, který soubor chcete použít. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Soubor projektu {0} neexistuje. - - Cannot specify both '--quiet' and '--verbose' options. - Možnosti --quiet a --verbose se nedají zadat spolu. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Příklady: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Upozornění NETSDK1174: Zkratka -p pro --project je zastaralá. Použijte prosím --project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf index 32d6954bd18f..9413f5fd572d 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - In "{0}" wurden mehrere MSBuild-Projektdateien gefunden. Geben Sie über die Option "--project" an, welche verwendet werden soll. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - In "{0}" wurde keine MSBuild-Projektdatei gefunden. Geben Sie das zu verwendende Projekt über die Option "--project" an. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + In "{0}" wurden mehrere MSBuild-Projektdateien gefunden. Geben Sie über die Option "--project" an, welche verwendet werden soll. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Die Projektdatei "{0}" ist nicht vorhanden. - - Cannot specify both '--quiet' and '--verbose' options. - Die Optionen "--quiet" und "--verbose" können nicht gleichzeitig angegeben werden. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Beispiele: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Warnung NETSDK1174: Die Abkürzung von „-p“ für „--project“ ist veraltet. Verwenden Sie „--project“. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf index 4c65ff3ab663..b8d10537fe5f 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - Se encontraron múltiples archivos del proyecto MSBuild en "{0}". Especifique cuál debe usarse con la opción --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - No se encontró ningún archivo del proyecto MSBuild en "{0}". Especifique qué proyecto debe utilizarse con la opción --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + Se encontraron múltiples archivos del proyecto MSBuild en "{0}". Especifique cuál debe usarse con la opción --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. El archivo de proyecto "{0}" no existe. - - Cannot specify both '--quiet' and '--verbose' options. - No se pueden especificar ambas opciones, "--quiet" y "--verbose". - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Ejemplos: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Advertencia NETSDK1174: La abreviatura de -p para --project está en desuso. Use --project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf index 20ab736f3a68..c8bee9490040 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - Plusieurs fichiers projet MSBuild trouvés dans '{0}'. Spécifiez celui qui doit être utilisé avec l'option --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - Fichier projet MSBuild introuvable dans '{0}'. Spécifiez le projet à utiliser avec l'option --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + Plusieurs fichiers projet MSBuild trouvés dans '{0}'. Spécifiez celui qui doit être utilisé avec l'option --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Le fichier projet '{0}' n'existe pas. - - Cannot specify both '--quiet' and '--verbose' options. - Impossible de spécifier les options '--quiet' et '--verbose'. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Exemples : + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. AVERTISSEMENT NETSDK1174 : l’abréviation de-p pour--project est déconseillée. Veuillez utiliser--project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf index b930c49675fc..8070c00fd221 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - In '{0}' sono stati trovati più file di progetto MSBuild. Per specificare quello desiderato, usare l'opzione --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - In '{0}' non è stato possibile trovare alcun file di progetto MSBuild. Per specificare quello desiderato, usare l'opzione --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + In '{0}' sono stati trovati più file di progetto MSBuild. Per specificare quello desiderato, usare l'opzione --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Il file di progetto '{0}' non esiste. - - Cannot specify both '--quiet' and '--verbose' options. - Non è possibile specificare entrambe le opzioni '--quiet' e '--verbose'. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Esempi: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Avviso NETSDK1174: l'abbreviazione di -p per --project è deprecata. Usare --project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf index 64088465e0c2..c1038676f546 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - 複数の MSBuild プロジェクト ファイルが '{0}' で見つかりました。使用するものを --project オプションで指定してください。 - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - '{0}' で MSBuild プロジェクト ファイルが見つかりませんでした。使用するプロジェクトを --project オプションで指定してください。 - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + 複数の MSBuild プロジェクト ファイルが '{0}' で見つかりました。使用するものを --project オプションで指定してください。 + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. プロジェクト ファイル '{0}' が存在しません。 - - Cannot specify both '--quiet' and '--verbose' options. - '--quiet' と '--verbose' の両方のオプションを指定することはできません。 - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Examples: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. 警告 NETSDK1174: --project の省略形である -p は推奨されていません。--project を使用してください。 diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf index 62c65710b56d..e41bd5548791 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - '{0}'에서 여러 MSBuild 프로젝트 파일을 찾았습니다. --project 옵션을 사용하여 사용할 파일을 지정하세요. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - '{0}'에서 MSBuild 프로젝트 파일을 찾을 수 없습니다. --project 옵션을 사용하여 사용할 파일을 지정하세요. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + '{0}'에서 여러 MSBuild 프로젝트 파일을 찾았습니다. --project 옵션을 사용하여 사용할 파일을 지정하세요. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. 프로젝트 파일 '{0}'이(가) 없습니다. - - Cannot specify both '--quiet' and '--verbose' options. - '--quiet' 옵션과 '--verbose' 옵션을 둘 다 지정할 수는 없습니다. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Examples: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. 경고 NETSDK1174: --project에 대한 약어 -p는 더 이상 사용되지 않습니다. --project를 사용하세요. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf index 5602735ba563..82b3fe490047 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - W elemencie „{0}” znaleziono wiele plików projektów MSBuild. Określ projekt do użycia za pomocą opcji --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - Nie można znaleźć pliku projektu MSBuild w elemencie „{0}”. Określ projekt do użycia za pomocą opcji --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + W elemencie „{0}” znaleziono wiele plików projektów MSBuild. Określ projekt do użycia za pomocą opcji --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Plik projektu „{0}” nie istnieje. - - Cannot specify both '--quiet' and '--verbose' options. - Nie można jednocześnie określić opcji „--quiet” i „--verbose”. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Przykłady: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Ostrzeżenie NETSDK1174: Skrót -p dla polecenia --project jest przestarzały. Użyj polecenia --project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf index 8cc2adbbb8e8..50d1a6500c7a 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - Foram encontrados vários arquivos de projeto do MSBuild em '{0}'. Especifique qual deve ser usado com a opção --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - Não foi possível encontrar um arquivo de projeto do MSBuild em '{0}'. Especifique qual projeto deve ser usado com a opção --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + Foram encontrados vários arquivos de projeto do MSBuild em '{0}'. Especifique qual deve ser usado com a opção --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. O arquivo de projeto '{0}' não existe. - - Cannot specify both '--quiet' and '--verbose' options. - Não é possível especificar as opções '--quiet' e '--verbose'. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Exemplos: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Aviso NETSDK1174: a abreviação de-p para--project é preterida. Use --project. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf index 3d21092dbf9a..de5e711d342f 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - В "{0}" обнаружено несколько файлов проекта MSBuild. Укажите файл, который нужно использовать, с помощью параметра --project. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - Не удалось найти файл проекта MSBuild в "{0}". Укажите проект, который нужно использовать, с помощью параметра --project. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + В "{0}" обнаружено несколько файлов проекта MSBuild. Укажите файл, который нужно использовать, с помощью параметра --project. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Файл проекта "{0}" не существует. - - Cannot specify both '--quiet' and '--verbose' options. - Невозможно одновременно указать параметры "--quiet" и "--verbose". - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Examples: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Предупреждение NETSDK1174: сокращение "-p" для "--project" не рекомендуется. Используйте "--project". diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf index 342799edfdd4..e5e8100659c5 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - '{0}' içinde birden fazla MSBuild proje dosyası bulundu. --project seçeneği ile kullanılacak proje dosyalarını belirtin. - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - '{0}' içinde MSBuild proje dosyası bulunamadı. --project seçeneği ile kullanılacak projeyi belirtin. - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + '{0}' içinde birden fazla MSBuild proje dosyası bulundu. --project seçeneği ile kullanılacak proje dosyalarını belirtin. + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. Proje dosyası '{0}' yok. - - Cannot specify both '--quiet' and '--verbose' options. - Hem '--quiet' hem de '--verbose' seçenekleri belirtilemez. - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Açıklamalar: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. Uyarı NETSDK1174: --project için -p kısaltması kullanımdan kaldırıldı. Lütfen --project kullanın. diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf index 59619297efcd..1389bd0cf5f3 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - 在“{0}”中找到多个 MSBuild 项目文件。请指定要将哪一个文件用于 --project 选项。 - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - 无法在“{0}”中找到 MSBuild 项目文件。请指定要用于 --project 选项的项目。 - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + 在“{0}”中找到多个 MSBuild 项目文件。请指定要将哪一个文件用于 --project 选项。 + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. 项目文件“{0}” 不存在。 - - Cannot specify both '--quiet' and '--verbose' options. - 不能同时指定 "--quiet" 和 "--verbose" 选项。 - {Locked="--quiet"}{Locked="--verbose"} - @@ -143,6 +143,11 @@ Examples: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. 警告 NETSDK1174: 已弃用使用缩写“-p”来代表“--project”。请使用“--project”。 diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf index 383e106d97cf..2614a48063f1 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf @@ -2,26 +2,26 @@ - - Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. - 在 '{0}' 中找到多個 MSBuild 專案檔。請指定要搭配 --project 選項使用的專案檔。 - {Locked="--project"} + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + - + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. - 在 '{0}' 中找不到 MSBuild 專案檔。請指定要搭配 --project 選項使用的專案。 - {Locked="--project"} + Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. + {Locked="MSBuild"}{Locked="--project"} + + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. + 在 '{0}' 中找到多個 MSBuild 專案檔。請指定要搭配 --project 選項使用的專案檔。 + {Locked="MSBuild"}{Locked="--project"} The project file '{0}' does not exist. 專案檔 '{0}' 不存在。 - - Cannot specify both '--quiet' and '--verbose' options. - 無法同時指定 '--quiet' 和 '--verbose' 選項。 - {Locked="--quiet"}{Locked="--verbose"} - @@ -142,6 +142,11 @@ Examples: + + The specified path '{0}' is invalid: {1} + The specified path '{0}' is invalid: {1} + + Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. 警告 NETSDK1174: --project 已取代縮寫 -p。請使用 --project。 diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems b/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems index 31ce6cd9eabc..4ee12260067b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.projitems @@ -10,6 +10,8 @@ - + + Designer + \ No newline at end of file diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 967cdc7fd884..844d76349966 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -82,6 +82,7 @@ internal sealed class VirtualProjectBuildingCommand : CommandBase ]; public static string TargetFrameworkVersion => Product.TargetFrameworkVersion; + public static string TargetFramework => $"net{Product.TargetFrameworkVersion}"; public bool NoRestore { get; init; } @@ -139,7 +140,7 @@ public VirtualProjectBuildingCommand( } .AsReadOnly()); - Builder = new VirtualProjectBuilder(entryPointFileFullPath, TargetFrameworkVersion, MSBuildArgs.GetResolvedTargets(), artifactsPath); + Builder = new VirtualProjectBuilder(entryPointFileFullPath, TargetFramework, MSBuildArgs.GetResolvedTargets(), artifactsPath); } public override int Execute() diff --git a/src/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj b/src/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj index 9de97043c331..68da3b67f0c9 100644 --- a/src/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj +++ b/src/Microsoft.DotNet.ProjectTools/Microsoft.DotNet.ProjectTools.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index ace65c8c4726..5a99e0e63732 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -41,7 +41,7 @@ public string ArtifactsPath public VirtualProjectBuilder( string entryPointFileFullPath, - string targetFrameworkVersion, + string targetFramework, string[]? requestedTargets = null, string? artifactsPath = null) { @@ -50,16 +50,16 @@ public VirtualProjectBuilder( EntryPointFileFullPath = entryPointFileFullPath; RequestedTargets = requestedTargets; ArtifactsPath = artifactsPath; - _defaultProperties = GetDefaultProperties(targetFrameworkVersion); + _defaultProperties = GetDefaultProperties(targetFramework); } /// /// Kept in sync with the default dotnet new console project file (enforced by DotnetProjectConvertTests.SameAsTemplate). /// - public static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFrameworkVersion) => + public static IEnumerable<(string name, string value)> GetDefaultProperties(string targetFramework) => [ ("OutputType", "Exe"), - ("TargetFramework", $"net{targetFrameworkVersion}"), + ("TargetFramework", targetFramework), ("ImplicitUsings", "enable"), ("Nullable", "enable"), ("PublishAot", "true"), @@ -76,6 +76,9 @@ public static string GetArtifactsPath(string entryPointFileFullPath) return GetTempSubpath(directoryName); } + public static string GetVirtualProjectPath(string entryPointFilePath) + => Path.ChangeExtension(entryPointFilePath, ".csproj"); + /// /// Obtains a temporary subdirectory for file-based app artifacts, e.g., /tmp/dotnet/runfile/. /// @@ -199,7 +202,7 @@ private ProjectInstance CreateProjectInstance( ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) { - var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj"); + var projectFileFullPath = GetVirtualProjectPath(EntryPointFileFullPath); var projectFileWriter = new StringWriter(); WriteProjectFile( diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestAssetExtensions.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestAssetExtensions.cs index bba89f12618b..50b32fadee23 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestAssetExtensions.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestAssetExtensions.cs @@ -8,5 +8,5 @@ internal static class TestAssetExtensions public static string GetWatchTestOutputPath(this TestAsset asset) => Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot ? Path.Combine(ciOutputRoot, ".hotreload", asset.Name) - : asset.Path + ".hotreload"; + : Path.Combine(asset.Path, ".hotreload"); } diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs index 2420ce3e7568..0c6775ea30ef 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/DotNetWatchOptionsTests.cs @@ -8,19 +8,19 @@ namespace Microsoft.DotNet.Watch.UnitTests; public class DotNetWatchOptionsTests { [Fact] - public void TryParse_RequiredProjectOption() + public void TryParse_RequiredSdkOption() { - // Project option is missing - var args = new[] { "--verbose", "a", "b" }; + // --sdk option is missing + var args = new[] { "--project", "proj", "a", "b" }; Assert.False(DotNetWatchOptions.TryParse(args, out var options)); Assert.Null(options); } [Fact] - public void TryParse_RequiredSdkOption() + public void TryParse_RequiredProjectOrFileOption() { - // Project option is missing - var args = new[] { "--project", "proj", "a", "b" }; + // --project and --file options are missing + var args = new[] { "--verbose", "a", "b" }; Assert.False(DotNetWatchOptions.TryParse(args, out var options)); Assert.Null(options); } @@ -31,10 +31,28 @@ public void TryParse_ProjectAndSdkPaths() var args = new[] { "--sdk", "sdk", "--project", "myproject.csproj" }; Assert.True(DotNetWatchOptions.TryParse(args, out var options)); Assert.Equal("sdk", options.SdkDirectory); - Assert.Equal("myproject.csproj", options.ProjectPath); + Assert.Equal("myproject.csproj", options.Project.PhysicalPath); + Assert.Empty(options.ApplicationArguments); + } + + [Fact] + public void TryParse_FilePath() + { + var args = new[] { "--sdk", "sdk", "--file", "file.cs" }; + Assert.True(DotNetWatchOptions.TryParse(args, out var options)); + Assert.Equal("sdk", options.SdkDirectory); + Assert.Equal("file.cs", options.Project.EntryPointFilePath); Assert.Empty(options.ApplicationArguments); } + [Fact] + public void TryParse_ProjectAndFilePaths() + { + var args = new[] { "--sdk", "sdk", "--project", "myproject.csproj", "--file", "file.cs" }; + Assert.False(DotNetWatchOptions.TryParse(args, out var options)); + Assert.Null(options); + } + [Fact] public void TryParse_ApplicationArguments() { @@ -95,22 +113,32 @@ public void TryParse_ConflictingOptions() } [Fact] - public void TryParse_MultipleOptionValues() + public void TryParse_Project_MultipleValues() { // Project option should only accept one value var args = new[] { "--sdk", "sdk", "--project", "proj1", "proj2" }; Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("proj1", options.ProjectPath); + Assert.Equal("proj1", options.Project.PhysicalPath); AssertEx.SequenceEqual(["proj2"], options.ApplicationArguments); } - + + [Fact] + public void TryParse_File_MultipleValues() + { + // Project option should only accept one value + var args = new[] { "--sdk", "sdk", "--file", "file1.cs", "file2.cs" }; + Assert.True(DotNetWatchOptions.TryParse(args, out var options)); + Assert.Equal("file1.cs", options.Project.EntryPointFilePath); + AssertEx.SequenceEqual(["file2.cs"], options.ApplicationArguments); + } + [Fact] public void TryParse_AllOptionsSet() { var args = new[] { "--sdk", "sdk", "--project", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" }; Assert.True(DotNetWatchOptions.TryParse(args, out var options)); - Assert.Equal("myapp.csproj", options.ProjectPath); + Assert.Equal("myapp.csproj", options.Project.PhysicalPath); Assert.Equal(LogLevel.Debug, options.LogLevel); Assert.True(options.NoLaunchProfile); AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], options.ApplicationArguments); diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs index 7bba809b7657..cd5c1d30ef00 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs @@ -19,7 +19,7 @@ public Test(ITestOutputHelper output, TestHotReloadAgent agent) { Logger = new TestLogger(output); AgentLogger = new TestLogger(output); - Client = new DefaultHotReloadClient(Logger, AgentLogger, startupHookPath: "", enableStaticAssetUpdates: true); + Client = new DefaultHotReloadClient(Logger, AgentLogger, startupHookPath: "", handlesStaticAssetUpdates: true); _cancellationSource = new CancellationTokenSource(); diff --git a/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json b/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json index b710223555e9..51d894aa4520 100644 --- a/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/BlazorWasmHosted50/Server/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "BlazorWasmHosted50.Server": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json index dc7b2f31b4b3..38b39f84b3d4 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5000", "environmentVariables": { diff --git a/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json index 8b203ef4a5b2..0f72a18452a0 100644 --- a/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json +++ b/test/TestAssets/TestProjects/WatchBrowserLaunchApp/Properties/launchSettings.json @@ -17,7 +17,7 @@ }, "BrowserLaunchApp": { "commandName": "Project", - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 2f8d127af74a..50ce37dca37e 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -444,9 +444,8 @@ public async Task ProjectReferences_Graph() var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory, muxerPath: MuxerPath); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); - var filesetFactory = new MSBuildFileSetFactory(projectA, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], processRunner, buildReporter); + var filesetFactory = new MSBuildFileSetFactory(projectA, targetFramework: null, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], processRunner, _logger, options); var result = await filesetFactory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); Assert.NotNull(result); @@ -509,9 +508,8 @@ public async Task MsbuildOutput() var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!, muxerPath: MuxerPath); var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); - var factory = new MSBuildFileSetFactory(project1Path, buildArguments: [], processRunner, buildReporter); + var factory = new MSBuildFileSetFactory(project1Path, targetFramework: null, buildArguments: [], processRunner, _logger, options); var result = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); Assert.Null(result); @@ -548,8 +546,7 @@ async Task VerifyTargetsEvaluation() var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDir, muxerPath: MuxerPath) with { TestOutput = testDir }; var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); var buildArguments = targetFramework != null ? new[] { "/p:TargetFramework=" + targetFramework } : []; - var buildReporter = new BuildReporter(_logger, new GlobalOptions(), options); - var factory = new MSBuildFileSetFactory(rootProjectPath, buildArguments, processRunner, buildReporter); + var factory = new MSBuildFileSetFactory(rootProjectPath, targetFramework: null, buildArguments, processRunner, _logger, options); var targetsResult = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); Assert.NotNull(targetsResult); diff --git a/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs b/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs new file mode 100644 index 000000000000..755009e2afe9 --- /dev/null +++ b/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.FileBasedPrograms; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class ProjectGraphFactoryTests(ITestOutputHelper output) +{ + private readonly TestAssetsManager _testAssetManager = new(output); + private readonly TestLogger _testLogger = new(); + + [Fact] + public void RegularProject() + { + var testAsset = _testAssetManager.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + var projectPath = Path.Combine(testAsset.Path, "WatchNoDepsApp.csproj"); + + var projectRepr = new ProjectRepresentation(projectPath, entryPointFilePath: null); + var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + + var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); + Assert.NotNull(graph); + + var root = graph.Graph.GraphRoots.Single(); + Assert.Equal(projectPath, root.ProjectInstance.FullPath); + } + + [Fact] + public void VirtualProject() + { + var dir = _testAssetManager.CreateTestDirectory().Path; + + var entryPointFilePath = Path.Combine(dir, "App.cs"); + File.WriteAllText(entryPointFilePath, """ + Console.WriteLine(1); + """); + + var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); + var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + + var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); + Assert.NotNull(graph); + + var root = graph.Graph.GraphRoots.Single(); + Assert.Equal(Path.ChangeExtension(entryPointFilePath, ".csproj"), root.ProjectInstance.FullPath); + } + + [Fact] + public void VirtualProject_Error() + { + var dir = _testAssetManager.CreateTestDirectory().Path; + + var entryPointFilePath = Path.Combine(dir, "App.cs"); + File.WriteAllText(entryPointFilePath, """ + #:project NonExistent.csproj + """); + + var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); + var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + + var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); + Assert.Null(graph); + + var message = string.Format(FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Combine(dir, "NonExistent.csproj"))); + + AssertEx.SequenceEqual( + [ + $"[Error] {entryPointFilePath}(1): {message}", + "[Debug] Failed to load project graph." + ], _testLogger.GetAndClearMessages()); + } + + [Fact] + public void VirtualProject_ProjectDirective() + { + var testAsset = _testAssetManager.CopyTestAsset("WatchNoDepsApp") + .WithSource(); + + var projectPath = Path.Combine(testAsset.Path, "WatchNoDepsApp.csproj"); + var scriptsDir = Path.Combine(testAsset.Path, ".scripts"); + Directory.CreateDirectory(scriptsDir); + + var entryPointFilePath = Path.Combine(scriptsDir, "Script.cs"); + + File.WriteAllText(entryPointFilePath, """ + #:project ..\WatchNoDepsApp.csproj + """); + + var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); + var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + + var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); + Assert.NotNull(graph); + + AssertEx.SequenceEqual( + [projectPath, Path.ChangeExtension(entryPointFilePath, ".csproj")], + graph.Graph.ProjectNodesTopologicallySorted.Select(p => p.ProjectInstance.FullPath)); + } +} diff --git a/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs b/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs index 15344134ce51..3d8c719204b3 100644 --- a/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs @@ -106,6 +106,13 @@ public void WatchOptions_NotPassedThrough( AssertEx.SequenceEqual([], options.CommandArguments); } + [Fact] + public void QuietAndVerbose() + { + VerifyErrors(["--quiet", "--verbose"], + expectedErrors: [$"[Error] {string.Format(Resources.Cannot_specify_both_0_and_1_options, "--quiet", "--verbose")}"]); + } + [Fact] public void RunOptions_LaunchProfile_Watch() { @@ -136,7 +143,7 @@ public void RunOptions_NoProfile_Watch() { var options = VerifyOptions(["--no-launch-profile", "run"]); - Assert.True(options.NoLaunchProfile); + Assert.True(!options.LaunchProfileName.HasValue); Assert.Equal("run", options.Command); AssertEx.SequenceEqual(["--no-launch-profile"], options.CommandArguments); } @@ -146,7 +153,7 @@ public void RunOptions_NoProfile_Run() { var options = VerifyOptions(["run", "--no-launch-profile"]); - Assert.True(options.NoLaunchProfile); + Assert.True(!options.LaunchProfileName.HasValue); Assert.Equal("run", options.Command); AssertEx.SequenceEqual(["--no-launch-profile"], options.CommandArguments); } @@ -156,7 +163,7 @@ public void RunOptions_NoProfile_Both() { var options = VerifyOptions(["--no-launch-profile", "run", "--no-launch-profile"]); - Assert.True(options.NoLaunchProfile); + Assert.True(!options.LaunchProfileName.HasValue); Assert.Equal("run", options.Command); AssertEx.SequenceEqual(["--no-launch-profile"], options.CommandArguments); } @@ -239,7 +246,8 @@ public void ParsedNonWatchOptionsAfterDashDash_NoLaunchProfile() { var options = VerifyOptions(["--", "--no-launch-profile"]); - Assert.False(options.NoLaunchProfile); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Null(options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--", "--no-launch-profile"], options.CommandArguments); } @@ -248,7 +256,8 @@ public void ParsedNonWatchOptionsAfterDashDash_LaunchProfile() { var options = VerifyOptions(["--", "--launch-profile", "p"]); - Assert.False(options.NoLaunchProfile); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Null(options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--", "--launch-profile", "p"], options.CommandArguments); } @@ -257,7 +266,8 @@ public void ParsedNonWatchOptionsAfterDashDash_Property() { var options = VerifyOptions(["--", "--property", "x=1"]); - Assert.False(options.NoLaunchProfile); + Assert.True(options.LaunchProfileName.HasValue); + Assert.Null(options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--", "--property", "x=1"], options.CommandArguments); } @@ -356,7 +366,7 @@ public void OptionDuplicates_Allowed_Bool( } [Fact] - public void MultiplePropertyValues() + public void OptionDuplicates_Property() { var options = VerifyOptions(["--property", "P1=V1", "run", "--property", "P2=V2"]); AssertEx.SequenceEqual(["--property:P1=V1", "--property:P2=V2", NugetInteractiveProperty], options.BuildArguments); @@ -366,7 +376,9 @@ public void MultiplePropertyValues() } [Theory] + [InlineData("--file")] [InlineData("--project")] + [InlineData("-p")] [InlineData("--framework")] public void OptionDuplicates_NotAllowed(string option) { @@ -390,30 +402,46 @@ public void ParsesRemainingArgs(string[] args, string[] expected) } [Fact] - public void CannotHaveQuietAndVerbose() + public void Project_ShortForm() { - VerifyErrors(["--quiet", "--verbose"], - $"[Error] {Resources.Error_QuietAndVerboseSpecified}"); + var options = VerifyOptions(["-p", "MyProject.csproj"], + expectedMessages: [$"[Warning] {Resources.Warning_ProjectAbbreviationDeprecated}"]); + + Assert.Equal("MyProject.csproj", options.ProjectPath); } [Fact] - public void ShortFormForProjectArgumentPrintsWarning() + public void Project_ShortAndLongForm() { - var options = VerifyOptions(["-p", "MyProject.csproj"], - expectedMessages: [$"[Warning] {Resources.Warning_ProjectAbbreviationDeprecated}"]); + VerifyErrors(["-p", "MyProject1.csproj", "--project", "MyProject2.csproj"], + expectedErrors: [$"[Error] {string.Format(Resources.Cannot_specify_both_0_and_1_options, "--project", "-p")}"]); + } - Assert.Equal("MyProject.csproj", options.ProjectPath); + [Theory] + [InlineData("-p")] + [InlineData("--project")] + public void Project_File(string projectOption) + { + VerifyErrors([projectOption, "MyProject1.csproj", "--file", "a.cs"], + expectedErrors: [$"[Error] {string.Format(Resources.Cannot_specify_both_0_and_1_options, "--file", projectOption)}"]); } [Fact] - public void LongFormForProjectArgumentWorks() + public void Project_LongForm() { var options = VerifyOptions(["--project", "MyProject.csproj"]); Assert.Equal("MyProject.csproj", options.ProjectPath); } [Fact] - public void LongFormForLaunchProfileArgumentWorks() + public void File() + { + var options = VerifyOptions(["--file", "MyFile.cs"]); + Assert.Equal("MyFile.cs", options.FilePath); + } + + [Fact] + public void LaunchProfile_LongForm() { var options = VerifyOptions(["--launch-profile", "CustomLaunchProfile"]); Assert.NotNull(options); @@ -421,7 +449,7 @@ public void LongFormForLaunchProfileArgumentWorks() } [Fact] - public void ShortFormForLaunchProfileArgumentWorks() + public void LaunchProfile_ShortForm() { var options = VerifyOptions(["-lp", "CustomLaunchProfile"]); Assert.Equal("CustomLaunchProfile", options.LaunchProfileName); diff --git a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs index 45b036f98470..6fabe683fdf2 100644 --- a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs @@ -131,6 +131,26 @@ public async Task Run_WithHotReloadEnabled_ReadsLaunchSettings_WhenUsingProjectO await App.AssertOutputLineEquals("Environment: Development"); } + [Fact] + public async Task Run_WithHotReloadEnabled_ReadsLaunchSettings_WhenUsingFileOption() + { + var testAsset = TestAssets.CopyTestAsset("WatchAppWithLaunchSettings") + .WithSource(); + + File.Move(Path.Combine(testAsset.Path, "Properties", "launchSettings.json"), Path.Combine(testAsset.Path, "Program.run.json")); + File.Delete(Path.Combine(testAsset.Path, "WatchAppWithLaunchSettings.csproj")); + + var directoryInfo = new DirectoryInfo(testAsset.Path); + + // Configure the working directory to be one level above the test app directory. + App.Start( + testAsset, + ["--file", Path.Combine(testAsset.Path, "Program.cs")], + workingDirectory: Path.GetFullPath(directoryInfo.Parent.FullName)); + + await App.AssertOutputLineEquals("Environment: Development"); + } + [CoreMSBuildOnlyFact(Skip = "https://github.com/dotnet/sdk/issues/29047")] public async Task Run_WithHotReloadEnabled_DoesNotReadConsoleIn_InNonInteractiveMode() { diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.GetProjectOptions.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.GetProjectOptions.cs new file mode 100644 index 000000000000..7f606283a24c --- /dev/null +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.GetProjectOptions.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class Program_GetProjectOptionsTests(ITestOutputHelper output) +{ + private readonly TestAssetsManager _testAssetManager = new(output); + private readonly TestLogger _testLogger = new(); + + private string CreateTempDirectory([CallerMemberName] string? callingMethod = null, string? identifier = null) + => _testAssetManager.CreateTestDirectory(callingMethod, identifier).Path; + + private CommandLineOptions ParseOptions(string[] args) + { + var output = new StringWriter(); + var options = CommandLineOptions.Parse(args, _testLogger, output: output, errorCode: out _); + Assert.NotNull(options); + return options; + } + + [Fact] + public void ExplicitProjectPath() + { + var tempDir = CreateTempDirectory(); + var projectPath = Path.Combine(tempDir, "Test.csproj"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions(["--project", projectPath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(projectPath, result.Representation.PhysicalPath); + Assert.Null(result.Representation.EntryPointFilePath); + Assert.Equal(tempDir, result.WorkingDirectory); + Assert.True(result.IsMainProject); + } + + [Fact] + public void ProjectInWorkingDirectory() + { + var tempDir = CreateTempDirectory(); + var projectPath = Path.Combine(tempDir, "MyApp.csproj"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions([]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(projectPath, result.Representation.PhysicalPath); + Assert.Null(result.Representation.EntryPointFilePath); + } + + [Fact] + public void MultipleProjects() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "App1.csproj"), ""); + File.WriteAllText(Path.Combine(tempDir, "App2.csproj"), ""); + + var options = ParseOptions([]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.Null(result); + AssertEx.SequenceEqual( + [$"[Error] {string.Format(Resources.Error_MultipleProjectsFound, tempDir)}"], + _testLogger.GetAndClearMessages()); + } + + [Fact] + public void NonExistentProject() + { + var tempDir = CreateTempDirectory(); + var projectPath = Path.Combine(tempDir, "NonExistent.csproj"); + var options = ParseOptions(["--project", projectPath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.Null(result); + AssertEx.SequenceEqual( + [$"[Error] {string.Format(Resources.Error_ProjectPath_NotFound, projectPath)}"], + _testLogger.GetAndClearMessages()); + } + + [Fact] + public void NoProjectsInDirectoryAndNoCSharpFile() + { + var tempDir = CreateTempDirectory(); + var emptyDir = Path.Combine(tempDir, "empty"); + Directory.CreateDirectory(emptyDir); + + var options = ParseOptions([]); + var result = Program.GetMainProjectOptions(options, emptyDir, _testLogger); + + Assert.Null(result); + AssertEx.SequenceEqual( + [$"[Error] {string.Format(Resources.Could_not_find_msbuild_project_file_in_0, emptyDir)}"], + _testLogger.GetAndClearMessages()); + } + + [Fact] + public void ProjectDirectory() + { + var tempDir = CreateTempDirectory(); + var subDir = Path.Combine(tempDir, "subdir"); + Directory.CreateDirectory(subDir); + var projectPath = Path.Combine(subDir, "SubApp.csproj"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions(["--project", subDir]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(projectPath, result.Representation.PhysicalPath); + } + + [Theory] + [InlineData("csproj")] + [InlineData("fsproj")] + [InlineData("vbproj")] + [InlineData("proj")] + public void ProjectFile_AcceptedExtension(string projExtension) + { + var tempDir = CreateTempDirectory(projExtension); + var projectPath = Path.Combine(tempDir, $"Test.{projExtension}"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions([]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(projectPath, result.Representation.PhysicalPath); + } + + [Theory] + [InlineData("shproj")] + public void ProjectFile_RejectedExtension(string projExtension) + { + var tempDir = CreateTempDirectory(projExtension); + var projectPath = Path.Combine(tempDir, $"Test.{projExtension}"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions([]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.Null(result); + AssertEx.SequenceEqual( + [$"[Error] {string.Format(Resources.Could_not_find_msbuild_project_file_in_0, tempDir)}"], + _testLogger.GetAndClearMessages()); + } + + [Fact] + public void InvalidFilePath() + { + var tempDir = CreateTempDirectory(); + var invalidPath = "invalid\0path.cs"; + + var options = ParseOptions(["--file", invalidPath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + string message; + try + { + Path.GetFullPath(invalidPath); + message = ""; + } + catch (Exception e) + { + message = e.Message; + } + + Assert.Null(result); + AssertEx.SequenceEqual( + [$"[Error] {string.Format(Resources.The_specified_path_0_is_invalid_1, invalidPath, message)}"], + _testLogger.GetAndClearMessages()); + } + + [Fact] + public void FilePathOption() + { + var tempDir = CreateTempDirectory(); + var csFilePath = Path.Combine(tempDir, "Program.cs"); + File.WriteAllText(csFilePath, "Console.WriteLine(\"Hello\");"); + + var options = ParseOptions(["--file", csFilePath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(csFilePath, result.Representation.EntryPointFilePath); + Assert.Null(result.Representation.PhysicalPath); + } + + [Fact] + public void CSharpFileSpecifiedAsArgument() + { + var tempDir = CreateTempDirectory(); + var csFilePath = Path.Combine(tempDir, "App.cs"); + File.WriteAllText(csFilePath, "Console.WriteLine(\"Hello\");"); + + // dotnet watch App.cs + var options = ParseOptions([csFilePath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(csFilePath, result.Representation.EntryPointFilePath); + } + + [Fact] + public void FileWithShebangSpecifiedAsArgument() + { + var tempDir = CreateTempDirectory(); + var filePath = Path.Combine(tempDir, "App.txt"); + File.WriteAllText(filePath, "#!"); + + // dotnet watch App.txt + var options = ParseOptions([filePath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(filePath, result.Representation.EntryPointFilePath); + } + + [Fact] + public void RelativeProjectPath() + { + var tempDir = CreateTempDirectory(); + var subDir = Path.Combine(tempDir, "subdir"); + Directory.CreateDirectory(subDir); + var projectPath = Path.Combine(subDir, "Test.csproj"); + File.WriteAllText(projectPath, ""); + + var options = ParseOptions(["--project", "subdir/Test.csproj"]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(projectPath, result.Representation.PhysicalPath); + } + + [Fact] + public void RelativeFilePath() + { + var tempDir = CreateTempDirectory(); + var csFilePath = Path.Combine(tempDir, "Script.cs"); + File.WriteAllText(csFilePath, "Console.WriteLine(\"Hello\");"); + + var options = ParseOptions(["--file", "Script.cs"]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(csFilePath, result.Representation.EntryPointFilePath); + } + + [Fact] + public void FilePathOptionTakesPrecedenceOverProjectPath() + { + var tempDir = CreateTempDirectory(); + var projectPath = Path.Combine(tempDir, "Test.csproj"); + File.WriteAllText(projectPath, ""); + var csFilePath = Path.Combine(tempDir, "Script.cs"); + File.WriteAllText(csFilePath, "Console.WriteLine(\"Hello\");"); + + var options = ParseOptions(["--file", csFilePath]); + var result = Program.GetMainProjectOptions(options, tempDir, _testLogger); + + Assert.NotNull(result); + Assert.Equal(csFilePath, result.Representation.EntryPointFilePath); + Assert.Null(result.Representation.PhysicalPath); + } +} diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs index 30c1e117f48a..b255256324bf 100644 --- a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Watch.UnitTests { - public class ProgramTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) + public class ProgramTests(ITestOutputHelper output) : DotNetWatchTestBase(output) { [Fact] public async Task ConsoleCancelKey() @@ -339,10 +339,9 @@ public async Task ProjectGraphLoadFailure() App.Start(testAsset, [], "AppWithDeps"); - await App.AssertOutputLineStartsWith("dotnet watch ⌚ Fix the error to continue or press Ctrl+C to exit."); - - App.AssertOutputContains(@"dotnet watch 🔨 Failed to load project graph."); - App.AssertOutputContains($"dotnet watch ❌ The project file could not be loaded. Could not find a part of the path '{Path.Combine(testAsset.Path, "AppWithDeps", "NonExistentDirectory", "X.csproj")}'"); + await App.WaitUntilOutputContains($"dotnet watch ❌ The project file could not be loaded. Could not find a part of the path '{Path.Combine(testAsset.Path, "AppWithDeps", "NonExistentDirectory", "X.csproj")}'."); + await App.WaitUntilOutputContains(@"dotnet watch 🔨 Failed to load project graph."); + await App.WaitUntilOutputContains("dotnet watch ⌚ Fix the error to continue or press Ctrl+C to exit."); } [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") diff --git a/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs b/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs index 976f970fdfe9..b6548a190acd 100644 --- a/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs +++ b/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs @@ -13,7 +13,7 @@ public class FileWatcherTests(ITestOutputHelper output) { private readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); private readonly TimeSpan NegativeTimeout = TimeSpan.FromSeconds(5); - private readonly TestAssetsManager _testAssetManager = new TestAssetsManager(output); + private readonly TestAssetsManager _testAssetManager = new(output); private async Task TestOperation( string dir, diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 75b77ee7d96e..446197247c6e 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -89,7 +89,7 @@ public async Task ProjectChange_UpdateDirectoryBuildPropsThenUpdateSource() Path.Combine(testAsset.Path, "Directory.Build.props"), src => src.Replace("false", "true")); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); await App.WaitUntilOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); App.Process.ClearOutput(); @@ -622,6 +622,37 @@ public async Task BaselineCompilationError() await App.WaitUntilOutputContains(""); } + [Theory] + [InlineData("PublishAot", "True")] + [InlineData("PublishTrimmed", "True")] + [InlineData("StartupHookSupport", "False")] + public async Task ChangeFileInAotProject(string propertyName, string propertyValue) + { + var tfm = ToolsetInfo.CurrentTargetFramework; + + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource() + .WithProjectChanges(project => + { + project.Root.Descendants() + .First(e => e.Name.LocalName == "PropertyGroup") + .Add(XElement.Parse($"<{propertyName}>{propertyValue}")); + }); + + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + App.Start(testAsset, ["--non-interactive"]); + + await App.WaitForOutputLineContaining($"[WatchHotReloadApp ({tfm})] " + MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'")); + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + App.Process.ClearOutput(); + + UpdateSourceFile(programPath, content => content.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"\");")); + + await App.WaitForOutputLineContaining($"[auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. + await App.WaitForOutputLineContaining(""); + } + [Fact] public async Task ChangeFileInFSharpProject() { @@ -651,9 +682,8 @@ open System.Threading [] let main argv = - while true do - printfn "Waiting" - Thread.Sleep(200) + printfn "Waiting" + Thread.Sleep(Timeout.Infinite) 0 """; @@ -661,7 +691,7 @@ open System.Threading File.WriteAllText(sourcePath, source); - App.Start(testAsset, []); + App.Start(testAsset, ["--non-interactive"]); await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); App.Process.ClearOutput(); @@ -1008,7 +1038,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() UpdateSourceFile(scopedCssPath, newCss); await App.WaitUntilOutputContains(MessageDescriptor.StaticAssetsChangesApplied); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); await App.WaitUntilOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/RazorClassLibrary.bundle.scp.css")); App.Process.ClearOutput(); @@ -1017,7 +1047,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;")); await App.WaitUntilOutputContains(MessageDescriptor.StaticAssetsChangesApplied); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); await App.WaitUntilOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/app.css")); App.Process.ClearOutput(); @@ -1062,7 +1092,7 @@ public async Task MauiBlazor() await App.WaitUntilOutputContains(MessageDescriptor.StaticAssetsChangesApplied); await App.WaitUntilOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); App.Process.ClearOutput(); // update scoped css: @@ -1071,7 +1101,7 @@ public async Task MauiBlazor() await App.WaitUntilOutputContains(MessageDescriptor.StaticAssetsChangesApplied); await App.WaitUntilOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); - await App.WaitUntilOutputContains(MessageDescriptor.NoCSharpChangesToApply); + await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); } // Test is timing out on .NET Framework: https://github.com/dotnet/sdk/issues/41669 @@ -1301,7 +1331,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains(MessageDescriptor.BuildSucceeded.GetMessage(serviceProjectPath)); await App.WaitUntilOutputContains(MessageDescriptor.ProjectsRebuilt); - await App.WaitUntilOutputContains($"dotnet watch ⭐ Starting project: {serviceProjectPath}"); + await App.WaitUntilOutputContains($"dotnet watch ⭐ Starting: '{serviceProjectPath}'"); App.Process.ClearOutput(); App.SendControlC(); diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 46e69aade1ab..42a531303ceb 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; @@ -17,12 +16,15 @@ public async Task ReferenceOutputAssembly_False() var workingDirectory = testAsset.Path; var hostDir = Path.Combine(testAsset.Path, "Host"); var hostProject = Path.Combine(hostDir, "Host.csproj"); + var hostProjectRepr = new ProjectRepresentation(hostProject, entryPointFilePath: null); - var options = TestOptions.GetProjectOptions(["--project", hostProject]); + var cmdOptions = TestOptions.GetCommandLineOptions(["--project", hostProject]); + var projectOptions = TestOptions.GetProjectOptions(cmdOptions); var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet"); - var factory = new ProjectGraphFactory(globalOptions: []); - var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, NullLogger.Instance, projectGraphRequired: false, CancellationToken.None); + var factory = new ProjectGraphFactory([hostProjectRepr], targetFramework: null, buildProperties: [], NullLogger.Instance); + var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false, CancellationToken.None); + Assert.NotNull(projectGraph); var processOutputReporter = new TestProcessOutputReporter(); @@ -34,7 +36,10 @@ public async Task ReferenceOutputAssembly_False() LoggerFactory = NullLoggerFactory.Instance, ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), Options = new(), - RootProjectOptions = TestOptions.ProjectOptions, + MainProjectOptions = TestOptions.ProjectOptions, + RootProjects = [hostProjectRepr], + BuildArguments = [], + TargetFramework = null, EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() @@ -42,7 +47,7 @@ public async Task ReferenceOutputAssembly_False() var handler = new CompilationHandler(context); - await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); + await handler.UpdateProjectGraphAsync(projectGraph.Graph, CancellationToken.None); // all projects are present AssertEx.SequenceEqual(["Host", "Lib2", "Lib", "A", "B"], handler.Workspace.CurrentSolution.Projects.Select(p => p.Name)); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 9495719fe794..2a49a0ee0b7f 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -60,16 +60,13 @@ private static async Task Launch(string projectPath, TestRuntime { var projectOptions = new ProjectOptions() { - IsRootProject = false, - ProjectPath = projectPath, + IsMainProject = false, + Representation = new ProjectRepresentation(projectPath, entryPointFilePath: null), WorkingDirectory = workingDirectory, - BuildArguments = [], Command = "run", CommandArguments = ["--project", projectPath], LaunchEnvironmentVariables = [], - LaunchProfileName = null, - NoLaunchProfile = true, - TargetFramework = null, + LaunchProfileName = default, }; RestartOperation? startOp = null; @@ -85,7 +82,7 @@ private static async Task Launch(string projectPath, TestRuntime Assert.NotNull(result); - await result.WaitForProcessRunningAsync(cancellationToken); + await result.Clients.WaitForConnectionEstablishedAsync(cancellationToken); return result; }); @@ -607,7 +604,7 @@ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind var ignoringChangeInExcludedFile = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInExcludedFile); var fileAdditionTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation); var reEvaluationCompleted = w.Reporter.RegisterSemaphore(MessageDescriptor.ReEvaluationCompleted); - var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoCSharpChangesToApply); + var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoManagedCodeChangesToApply); Log("Waiting for changes..."); await waitingForChanges.WaitAsync(w.ShutdownSource.Token); diff --git a/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs b/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs index f05b05bb47bd..002380ea98a1 100644 --- a/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs +++ b/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs @@ -48,7 +48,9 @@ public void LoadsLaunchProfiles() } """); - var projectPath = Path.Combine(project.TestRoot, "Project1", "Project1.csproj"); + var projectPath = new ProjectRepresentation( + projectPath: Path.Combine(project.TestRoot, "Project1", "Project1.csproj"), + entryPointFilePath: null); var expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, launchProfileName: "http", _logger); Assert.NotNull(expected); @@ -81,7 +83,9 @@ public void DefaultLaunchProfileWithoutProjectCommand() } """); - var projectPath = Path.Combine(project.Path, "Project1", "Project1.csproj"); + var projectPath = new ProjectRepresentation( + projectPath: Path.Combine(project.Path, "Project1", "Project1.csproj"), + entryPointFilePath: null); var expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, launchProfileName: null, _logger); Assert.Null(expected); diff --git a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs index f5d976522af9..f567640dfc9b 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs +++ b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs @@ -7,9 +7,11 @@ namespace Microsoft.DotNet.Watch.UnitTests; internal class MockFileSetFactory() : MSBuildFileSetFactory( rootProjectFile: "test.csproj", + targetFramework: null, buildArguments: [], new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), - new BuildReporter(NullLogger.Instance, new GlobalOptions(), TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options)) + NullLogger.Instance, + TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options) { public Func? TryCreateImpl; diff --git a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs index 9116bbecb0b5..7c74d6978523 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs @@ -12,7 +12,7 @@ internal static class TestOptions public static int GetTestPort() => Interlocked.Increment(ref s_testPort); - public static readonly ProjectOptions ProjectOptions = GetProjectOptions([]); + public static readonly ProjectOptions ProjectOptions = GetProjectOptions(GetCommandLineOptions([])); public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = "", string muxerPath = "", TestAsset? asset = null) => new(workingDirectory, muxerPath, ProcessCleanupTimeout: null, IsPollingEnabled: true, TestFlags: TestFlags.RunningAsTest, TestOutput: asset != null ? asset.GetWatchTestOutputPath() : ""); @@ -20,9 +20,6 @@ public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = public static CommandLineOptions GetCommandLineOptions(string[] args) => CommandLineOptions.Parse(args, NullLogger.Instance, TextWriter.Null, out _) ?? throw new InvalidOperationException(); - public static ProjectOptions GetProjectOptions(string[]? args = null) - { - var options = GetCommandLineOptions(args ?? []); - return options.GetProjectOptions(options.ProjectPath ?? "test.csproj", workingDirectory: ""); - } + public static ProjectOptions GetProjectOptions(CommandLineOptions options) + => options.GetMainProjectOptions(new ProjectRepresentation(options.ProjectPath ?? "test.csproj", entryPointFilePath: null), workingDirectory: ""); } diff --git a/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs b/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs index f8f130cf2a7f..f78dee02e930 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestRuntimeProcessLauncher.cs @@ -9,7 +9,7 @@ internal class TestRuntimeProcessLauncher(ProjectLauncher projectLauncher) : IRu { public class Factory(Action? initialize = null) : IRuntimeProcessLauncherFactory { - public IRuntimeProcessLauncher TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) + public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher) { var service = new TestRuntimeProcessLauncher(projectLauncher); initialize?.Invoke(service); diff --git a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs index d673b618826c..5fe0ea928710 100644 --- a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs +++ b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs @@ -26,7 +26,10 @@ private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementali LoggerFactory = NullLoggerFactory.Instance, ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), Options = new(), - RootProjectOptions = TestOptions.ProjectOptions, + MainProjectOptions = TestOptions.ProjectOptions, + RootProjects = [TestOptions.ProjectOptions.Representation], + TargetFramework = null, + BuildArguments = [], EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() diff --git a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs index 895a9d6b779e..a74be1cd7c0a 100644 --- a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs +++ b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs @@ -14,6 +14,8 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen environmentOptions ??= TestOptions.GetEnvironmentOptions(); var processOutputReporter = new TestProcessOutputReporter(); + var cmdOptions = TestOptions.GetCommandLineOptions(args ?? []); + var projectOptions = TestOptions.GetProjectOptions(cmdOptions); return new() { @@ -23,7 +25,10 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen BuildLogger = NullLogger.Instance, ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), Options = new(), - RootProjectOptions = TestOptions.GetProjectOptions(args), + MainProjectOptions = projectOptions, + RootProjects = [projectOptions.Representation], + TargetFramework = cmdOptions.TargetFramework, + BuildArguments = cmdOptions.BuildArguments, EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 1b800a103c8b..7b6365d8733e 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -1746,7 +1746,7 @@ private static void Convert(string inputCSharp, out string actualProject, out st var directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: !force, diagnosticBag); directives = VirtualProjectBuilder.EvaluateDirectives(project: null, directives, sourceFile, diagnosticBag); var projectWriter = new StringWriter(); - VirtualProjectBuilder.WriteProjectFile(projectWriter, directives, VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFrameworkVersion), isVirtualProject: false); + VirtualProjectBuilder.WriteProjectFile(projectWriter, directives, VirtualProjectBuilder.GetDefaultProperties(VirtualProjectBuildingCommand.TargetFramework), isVirtualProject: false); actualProject = projectWriter.ToString(); actualCSharp = VirtualProjectBuilder.RemoveDirectivesFromFile(directives, sourceFile.Text)?.ToString(); }