diff --git a/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Program.cs b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Program.cs new file mode 100644 index 000000000000..0e8a498b81e8 --- /dev/null +++ b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Versioning; +using System.Threading; + +var assembly = typeof(C).Assembly; + +Console.WriteLine("Started"); + +// Process ID is insufficient because PID's may be reused. +Console.WriteLine($"Process identifier = {Process.GetCurrentProcess().Id}, {Process.GetCurrentProcess().StartTime:hh:mm:ss.FF}"); +Console.WriteLine($"DOTNET_WATCH = {Environment.GetEnvironmentVariable("DOTNET_WATCH")}"); +Console.WriteLine($"DOTNET_WATCH_ITERATION = {Environment.GetEnvironmentVariable("DOTNET_WATCH_ITERATION")}"); +Console.WriteLine($"Arguments = {string.Join(",", args)}"); +Console.WriteLine($"Version = {assembly.GetCustomAttributes().FirstOrDefault()?.Version ?? ""}"); +Console.WriteLine($"TFM = {assembly.GetCustomAttributes().FirstOrDefault()?.FrameworkName ?? ""}"); +Console.WriteLine($"Configuration = {assembly.GetCustomAttributes().FirstOrDefault()?.Configuration ?? ""}"); + +Loop(); + +static void Loop() +{ + while (true) + { + Console.WriteLine("."); + Thread.Sleep(1000); + } +} + +class C { } diff --git a/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Properties/launchSettings.json b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Properties/launchSettings.json new file mode 100644 index 000000000000..7bd33c8a2537 --- /dev/null +++ b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "app": { + "commandName": "Project", + "environmentVariables": { + "EnvironmentFromProfile": "Development" + } + }, + "P1": { + "commandName": "Project", + "commandLineArgs": "\"Arg1 from launch profile\" \"Arg2 from launch profile\"" + } + } + } diff --git a/src/Assets/TestProjects/WatchHotReloadAppCustomHost/WatchHotReloadApp.csproj b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/WatchHotReloadApp.csproj new file mode 100644 index 000000000000..fb39d3d96409 --- /dev/null +++ b/src/Assets/TestProjects/WatchHotReloadAppCustomHost/WatchHotReloadApp.csproj @@ -0,0 +1,7 @@ + + + $(CurrentTargetFramework) + Exe + "Argument Specified in Props" + + diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index 3868d739e94f..d60353264e1e 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs @@ -91,7 +91,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance currentRunCancellationSource.Token)) using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter)) { - _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{string.Join(" ", processSpec.Arguments)}'"); + _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{processSpec.GetArgumentsDisplay()}'"); var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token); _reporter.Output("Started", emoji: "🚀"); diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index 313b050649ef..8fd1799697b2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -129,7 +129,7 @@ public async Task WatchAsync(DotNetWatchContext context, CancellationToken cance // when the solution captures state of the file after the changes has already been made. await hotReload.InitializeAsync(context, cancellationToken); - _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{string.Join(" ", processSpec.Arguments ?? Array.Empty())}'"); + _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{processSpec.GetArgumentsDisplay()}'"); var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token); _reporter.Output("Started", emoji: "🚀"); @@ -298,10 +298,28 @@ private void ConfigureExecutable(DotNetWatchContext context, ProcessSpec process var project = context.FileSet?.Project; Debug.Assert(project != null); + // RunCommand property specifies the host to use to run the project. + // RunArguments then specifies the arguments to the host. + // Arguments to the executable should follow the host arguments. + processSpec.Executable = project.RunCommand; + if (!string.IsNullOrEmpty(project.RunArguments)) { - processSpec.EscapedArguments = project.RunArguments; + var escapedArguments = project.RunArguments; + + if (processSpec.EscapedArguments != null) + { + escapedArguments += " " + processSpec.EscapedArguments; + } + + if (processSpec.Arguments != null) + { + escapedArguments += " " + CommandLineUtilities.JoinArguments(processSpec.Arguments); + } + + processSpec.EscapedArguments = escapedArguments; + processSpec.Arguments = null; } if (!string.IsNullOrEmpty(project.RunWorkingDirectory)) diff --git a/src/BuiltInTools/dotnet-watch/Internal/CommandLineUtilities.cs b/src/BuiltInTools/dotnet-watch/Internal/CommandLineUtilities.cs new file mode 100644 index 000000000000..ff710c7666b8 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Internal/CommandLineUtilities.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Copied from dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs +namespace Microsoft.DotNet.Watcher; + +internal static class CommandLineUtilities +{ + public static string JoinArguments(IEnumerable arguments) + { + var builder = new StringBuilder(); + AppendArguments(builder, arguments); + return builder.ToString(); + } + + public static void AppendArguments(StringBuilder builder, IEnumerable arguments) + { + foreach (var arg in arguments) + { + AppendArgument(builder, arg); + } + } + + private static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length != 0) + { + stringBuilder.Append(' '); + } + + // Parsing rules for non-argv[0] arguments: + // - Backslash is a normal character except followed by a quote. + // - 2N backslashes followed by a quote ==> N literal backslashes followed by unescaped quote + // - 2N+1 backslashes followed by a quote ==> N literal backslashes followed by a literal quote + // - Parsing stops at first whitespace outside of quoted region. + // - (post 2008 rule): A closing quote followed by another quote ==> literal quote, and parsing remains in quoting mode. + if (argument.Length != 0 && ContainsNoWhitespaceOrQuotes(argument)) + { + // Simple case - no quoting or changes needed. + stringBuilder.Append(argument); + } + else + { + stringBuilder.Append(Quote); + int idx = 0; + while (idx < argument.Length) + { + char c = argument[idx++]; + if (c == Backslash) + { + int numBackSlash = 1; + while (idx < argument.Length && argument[idx] == Backslash) + { + idx++; + numBackSlash++; + } + + if (idx == argument.Length) + { + // We'll emit an end quote after this so must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2); + } + else if (argument[idx] == Quote) + { + // Backslashes will be followed by a quote. Must double the number of backslashes. + stringBuilder.Append(Backslash, numBackSlash * 2 + 1); + stringBuilder.Append(Quote); + idx++; + } + else + { + // Backslash will not be followed by a quote, so emit as normal characters. + stringBuilder.Append(Backslash, numBackSlash); + } + + continue; + } + + if (c == Quote) + { + // Escape the quote so it appears as a literal. This also guarantees that we won't end up generating a closing quote followed + // by another quote (which parses differently pre-2008 vs. post-2008.) + stringBuilder.Append(Backslash); + stringBuilder.Append(Quote); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append(Quote); + } + } + + private static bool ContainsNoWhitespaceOrQuotes(string s) + { + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsWhiteSpace(c) || c == Quote) + { + return false; + } + } + + return true; + } + + private const char Quote = '\"'; + private const char Backslash = '\\'; +} diff --git a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs index cd52ea0b7880..8f4e86dd1427 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs @@ -65,8 +65,7 @@ public async Task RunAsync(ProcessSpec processSpec, CancellationToken cance stopwatch.Start(); process.Start(); - var args = processSpec.EscapedArguments ?? string.Join(" ", processSpec.Arguments ?? Array.Empty()); - _reporter.Verbose($"Started '{processSpec.Executable}' '{args}' with process id {process.Id}", emoji: "🚀"); + _reporter.Verbose($"Started '{processSpec.Executable}' with arguments '{processSpec.GetArgumentsDisplay()}': process id {process.Id}", emoji: "🚀"); if (readOutput) { diff --git a/src/BuiltInTools/dotnet-watch/ProcessSpec.cs b/src/BuiltInTools/dotnet-watch/ProcessSpec.cs index 92f743b13d9d..b756aff1a2f8 100644 --- a/src/BuiltInTools/dotnet-watch/ProcessSpec.cs +++ b/src/BuiltInTools/dotnet-watch/ProcessSpec.cs @@ -31,5 +31,8 @@ internal sealed class ProcessSpecEnvironmentVariables : Dictionary Path.GetFileNameWithoutExtension(Executable); + + public string GetArgumentsDisplay() + => EscapedArguments ?? CommandLineUtilities.JoinArguments(Arguments ?? []); } } diff --git a/src/Tests/dotnet-watch.Tests/Watch/ProgramTests.cs b/src/Tests/dotnet-watch.Tests/Watch/ProgramTests.cs index e0f2bfe413fb..30571d57f1ef 100644 --- a/src/Tests/dotnet-watch.Tests/Watch/ProgramTests.cs +++ b/src/Tests/dotnet-watch.Tests/Watch/ProgramTests.cs @@ -58,6 +58,24 @@ public async Task Arguments(string[] arguments, string expectedApplicationArgs) Assert.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); } + [Theory] + [InlineData(new[] { "--no-hot-reload", "--", "run", "args" }, "Argument Specified in Props,run,args")] + [InlineData(new[] { "--", "run", "args" }, "Argument Specified in Props,run,args")] + // if arguments specified on command line the ones from launch profile are ignored + [InlineData(new[] { "-lp", "P1", "--", "run", "args" },"Argument Specified in Props,run,args")] + // if no arguments specified on command line the ones from launch profile are added + [InlineData(new[] { "-lp", "P1" }, "Argument Specified in Props,Arg1 from launch profile,Arg2 from launch profile")] + public async Task Arguments_HostArguments(string[] arguments, string expectedApplicationArgs) + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadAppCustomHost", identifier: string.Join(",", arguments)) + .WithSource() + .Path; + + App.Start(testAsset, arguments); + + Assert.Equal(expectedApplicationArgs, await App.AssertOutputLineStartsWith("Arguments = ")); + } + [Fact] public async Task RunArguments_NoHotReload() {