diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs index b7d669a89216..b3daeb70b129 100644 --- a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs @@ -7,6 +7,7 @@ using System.Threading.Channels; using Aspire.Tools.Service; using Microsoft.Build.Graph; +using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; @@ -106,7 +107,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 +144,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); @@ -221,7 +222,7 @@ private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) return new() { IsRootProject = false, - ProjectPath = projectLaunchInfo.ProjectPath, + Representation = ProjectRepresentation.FromProjectOrEntryPointFilePath(projectLaunchInfo.ProjectPath), WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(), BuildArguments = _hostProjectOptions.BuildArguments, Command = "run", diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs index c841191e0208..3c60bdfc46b5 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs @@ -128,6 +128,6 @@ 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(); + ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.Representation, projectOptions.LaunchProfileName, logger)) ?? new(); } } diff --git a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs index 966ea12c87c4..7641d01e7e5e 100644 --- a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs @@ -32,12 +32,25 @@ public void WatchFiles(FileWatcher fileWatcher) fileWatcher.WatchFiles(BuildFiles); } + public static ImmutableDictionary GetGlobalBuildOptions(IEnumerable buildArguments, EnvironmentOptions environmentOptions) + { + // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md + + return CommandLineOptions.ParseBuildProperties(buildArguments) + .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) + .SetItem(PropertyNames.DotNetWatchBuild, "true") + .SetItem(PropertyNames.DesignTimeBuild, "true") + .SetItem(PropertyNames.SkipCompilerExecution, "true") + .SetItem(PropertyNames.ProvideCommandLineArgs, "true") + // F# targets depend on host path variable: + .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); + } + /// /// Loads project graph and performs design-time build. /// public static EvaluationResult? TryCreate( - string rootProjectPath, - IEnumerable buildArguments, + ProjectGraphFactory factory, ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions, @@ -46,20 +59,7 @@ public void WatchFiles(FileWatcher fileWatcher) { var buildReporter = new BuildReporter(logger, options, environmentOptions); - // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md - - var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) - .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) - .SetItem(PropertyNames.DotNetWatchBuild, "true") - .SetItem(PropertyNames.DesignTimeBuild, "true") - .SetItem(PropertyNames.SkipCompilerExecution, "true") - .SetItem(PropertyNames.ProvideCommandLineArgs, "true") - // F# targets depend on host path variable: - .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); - - var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph( - rootProjectPath, - globalOptions, + var projectGraph = factory.TryLoadProjectGraph( logger, projectGraphRequired: true, cancellationToken); @@ -77,7 +77,7 @@ public void WatchFiles(FileWatcher fileWatcher) { if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) { - logger.LogError("Failed to restore project '{Path}'.", rootProjectPath); + logger.LogError("Failed to restore '{Path}'.", rootProjectPath); loggers.ReportOutput(); return null; } diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs new file mode 100644 index 000000000000..ce33c2b737bc --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs @@ -0,0 +1,121 @@ +// 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.Collections.ObjectModel; +using System.Diagnostics; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Evaluation.Context; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.DotNet.Cli.Commands.Run; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProjectGraphFactory +{ + /// + /// Reuse with XML element caching to improve performance. + /// + /// The cache is automatically updated when build files change. + /// https://github.com/dotnet/msbuild/blob/b6f853defccd64ae1e9c7cf140e7e4de68bff07c/src/Build/Definition/ProjectCollection.cs#L343-L354 + /// + private readonly ProjectCollection _collection; + + private readonly ImmutableDictionary _globalOptions; + private readonly ProjectRepresentation _rootProject; + + // TODO: support for non-root virtual projects + private readonly VirtualProjectBuildingCommand? _virtualRootProjectBuilder; + + public ProjectGraphFactory( + ProjectRepresentation rootProject, + ImmutableDictionary globalOptions) + { + _collection = new( + globalProperties: globalOptions, + loggers: [], + remoteLoggers: [], + ToolsetDefinitionLocations.Default, + maxNodeCount: 1, + onlyLogCriticalEvents: false, + loadProjectsReadOnly: false, + useAsynchronousLogging: false, + reuseProjectRootElementCache: true); + + _globalOptions = globalOptions; + _rootProject = rootProject; + + if (rootProject.EntryPointFilePath != null) + { + _virtualRootProjectBuilder = new VirtualProjectBuildingCommand(rootProject.EntryPointFilePath, MSBuildArgs.FromProperties(new ReadOnlyDictionary(globalOptions))); + } + } + + /// + /// Tries to create a project graph by running the build evaluation phase on the . + /// + public ProjectGraph? TryLoadProjectGraph( + ILogger logger, + bool projectGraphRequired, + CancellationToken cancellationToken) + { + var entryPoint = new ProjectGraphEntryPoint(_rootProject.ProjectGraphPath, _globalOptions); + try + { + return new ProjectGraph([entryPoint], _collection, CreateProjectInstance, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + // ProejctGraph aggregates OperationCanceledException exception, + // throw here to propagate the cancellation. + cancellationToken.ThrowIfCancellationRequested(); + + logger.LogDebug("Failed to load project graph."); + + if (e is AggregateException { InnerExceptions: var innerExceptions }) + { + foreach (var inner in innerExceptions) + { + Report(inner); + } + } + else + { + Report(e); + } + + void Report(Exception e) + { + if (projectGraphRequired) + { + logger.LogError(e.Message); + } + else + { + logger.LogWarning(e.Message); + } + } + } + + return null; + } + + private ProjectInstance CreateProjectInstance(string projectPath, Dictionary globalProperties, ProjectCollection projectCollection) + { + if (_virtualRootProjectBuilder != null && projectPath == _rootProject.ProjectGraphPath) + { + return _virtualRootProjectBuilder.CreateProjectInstance(projectCollection); + } + + return new ProjectInstance( + projectPath, + globalProperties, + toolsVersion: "Current", + subToolsetVersion: null, + projectCollection); + } +} diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs index 56bcba3427e6..6cb4c6b57a04 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -1,82 +1,14 @@ // 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.Evaluation; using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.DotNet.Cli; -using Microsoft.Extensions.Logging; -using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.DotNet.Watch; internal static class ProjectGraphUtilities { - /// - /// Tries to create a project graph by running the build evaluation phase on the . - /// - public static ProjectGraph? TryLoadProjectGraph( - string rootProjectFile, - ImmutableDictionary globalOptions, - ILogger logger, - bool projectGraphRequired, - CancellationToken cancellationToken) - { - var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); - try - { - // Create a new project collection that does not reuse element cache - // to work around https://github.com/dotnet/msbuild/issues/12064: - var collection = new ProjectCollection( - globalProperties: globalOptions, - loggers: [], - remoteLoggers: [], - ToolsetDefinitionLocations.Default, - maxNodeCount: 1, - onlyLogCriticalEvents: false, - loadProjectsReadOnly: false, - useAsynchronousLogging: false, - reuseProjectRootElementCache: false); - - return new ProjectGraph([entryPoint], collection, projectInstanceFactory: null, cancellationToken); - } - catch (Exception e) when (e is not OperationCanceledException) - { - // ProejctGraph aggregates OperationCanceledException exception, - // throw here to propagate the cancellation. - cancellationToken.ThrowIfCancellationRequested(); - - logger.LogDebug("Failed to load project graph."); - - if (e is AggregateException { InnerExceptions: var innerExceptions }) - { - foreach (var inner in innerExceptions) - { - Report(inner); - } - } - else - { - Report(e); - } - - void Report(Exception e) - { - if (projectGraphRequired) - { - logger.LogError(e.Message); - } - else - { - logger.LogWarning(e.Message); - } - } - } - - return null; - } - public static string GetDisplayName(this ProjectGraphNode projectNode) => $"{Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath)} ({projectNode.GetTargetFramework()})"; diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectNodeMap.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectNodeMap.cs index a916346da9cc..1e2b1dca5875 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectNodeMap.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectNodeMap.cs @@ -24,7 +24,7 @@ public IReadOnlyList GetProjectNodes(string projectPath) return rootProjectNodes; } - logger.LogError("Project '{ProjectPath}' not found in the project graph.", projectPath); + logger.LogError("Project '{PhysicalPath}' not found in the project graph.", projectPath); return []; } @@ -40,7 +40,7 @@ public IReadOnlyList GetProjectNodes(string projectPath) { if (projectNodes.Count > 1) { - logger.LogError("Project '{ProjectPath}' targets multiple frameworks. Specify which framework to run using '--framework'.", projectPath); + logger.LogError("Project '{PhysicalPath}' targets multiple frameworks. Specify which framework to run using '--framework'.", projectPath); return null; } @@ -55,7 +55,7 @@ public IReadOnlyList GetProjectNodes(string projectPath) if (candidate != null) { // shouldn't be possible: - logger.LogWarning("Project '{ProjectPath}' has multiple instances targeting {TargetFramework}.", projectPath, targetFramework); + logger.LogWarning("Project '{PhysicalPath}' has multiple instances targeting {TargetFramework}.", projectPath, targetFramework); return candidate; } @@ -65,7 +65,7 @@ public IReadOnlyList GetProjectNodes(string projectPath) if (candidate == null) { - logger.LogError("Project '{ProjectPath}' doesn't have a target for {TargetFramework}.", projectPath, targetFramework); + logger.LogError("Project '{PhysicalPath}' doesn't have a target for {TargetFramework}.", projectPath, targetFramework); } return candidate; diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs index 95e98a584af6..ab1fd9417867 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs @@ -23,6 +23,7 @@ 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; } @@ -57,7 +58,7 @@ internal sealed class CommandLineOptions { if (v.GetValue(quietOption) && v.GetValue(verboseOption)) { - v.AddError(Resources.Error_QuietAndVerboseSpecified); + v.AddError(string.Format(Resources.Cannot_specify_both_0_and_1_options, quietOption.Name, verboseOption.Name)); } }); @@ -73,6 +74,7 @@ internal sealed class CommandLineOptions // Options we need to know about that are passed through to the subcommand: var shortProjectOption = new Option("-p") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; var longProjectOption = new Option("--project") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; + var fileOption = new Option("--file") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; var launchProfileOption = new Option("--launch-profile", "-lp") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; var noLaunchProfileOption = new Option("--no-launch-profile") { Hidden = true, Arity = ArgumentArity.Zero }; @@ -88,9 +90,29 @@ internal sealed class CommandLineOptions rootCommand.Options.Add(longProjectOption); rootCommand.Options.Add(shortProjectOption); + rootCommand.Options.Add(fileOption); rootCommand.Options.Add(launchProfileOption); rootCommand.Options.Add(noLaunchProfileOption); + rootCommand.Validators.Add(v => + { + var hasLongProjectOption = v.GetValue(longProjectOption) != null; + var hasShortProjectOption = v.GetValue(shortProjectOption) != null; + + if (hasLongProjectOption && hasShortProjectOption) + { + v.AddError(string.Format(Resources.Cannot_specify_both_0_and_1_options, longProjectOption.Name, shortProjectOption.Name)); + } + + if (v.GetValue(fileOption) != null && (hasLongProjectOption || hasShortProjectOption)) + { + v.AddError(string.Format( + Resources.Cannot_specify_both_0_and_1_options, + fileOption.Name, + hasLongProjectOption ? longProjectOption.Name : shortProjectOption.Name)); + } + }); + // We process all tokens that do not match any of the above options // to find the subcommand (the first unmatched token preceding "--") // and all its options and arguments. @@ -186,6 +208,7 @@ internal sealed class CommandLineOptions ExplicitCommand = explicitCommand?.Name, ProjectPath = projectValue, + FilePath = parseResult.GetValue(fileOption), LaunchProfileName = parseResult.GetValue(launchProfileOption), NoLaunchProfile = parseResult.GetValue(noLaunchProfileOption), BuildArguments = buildArguments, @@ -363,11 +386,12 @@ private static int IndexOf(IReadOnlyList list, Func predicate) return -1; } - public ProjectOptions GetProjectOptions(string projectPath, string workingDirectory) - => new() + public ProjectOptions GetProjectOptions(ProjectRepresentation project, string workingDirectory) + { + return new() { IsRootProject = true, - ProjectPath = projectPath, + Representation = project, WorkingDirectory = workingDirectory, Command = Command, CommandArguments = CommandArguments, @@ -377,6 +401,7 @@ public ProjectOptions GetProjectOptions(string projectPath, string workingDirect BuildArguments = BuildArguments, TargetFramework = TargetFramework, }; + } // Parses name=value pairs passed to --property. Skips invalid input. public static IEnumerable<(string key, string value)> ParseBuildProperties(IEnumerable arguments) diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/ProjectOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/ProjectOptions.cs index 12b8b889f1f7..3c321b69d408 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/ProjectOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/ProjectOptions.cs @@ -6,7 +6,7 @@ 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; } public required string WorkingDirectory { get; init; } public required string? TargetFramework { get; init; } public required IReadOnlyList BuildArguments { get; init; } diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/ProjectRepresentation.cs b/src/BuiltInTools/dotnet-watch/CommandLine/ProjectRepresentation.cs new file mode 100644 index 000000000000..574a1f7e97e6 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/CommandLine/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.Cli.Commands.Run; + +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 ?? VirtualProjectBuildingCommand.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: null, entryPointFilePath: projectOrEntryPointFilePath) + : new(projectPath: projectOrEntryPointFilePath, entryPointFilePath: null); + + public ProjectRepresentation WithProjectGraphPath(string projectGraphPath) + => new(projectGraphPath, PhysicalPath, EntryPointFilePath); +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 55770d524293..e20d8ff755ef 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -232,7 +232,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested) { // Process exited during initialization. This should not happen since we control the process during this time. - _logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); + _logger.LogError("Failed to launch '{PhysicalPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); return null; } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 565bc7f9062b..5ed2474d290d 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -20,6 +20,7 @@ internal sealed class HotReloadDotNetWatcher private readonly RestartPrompt? _rudeEditRestartPrompt; private readonly DotNetWatchContext _context; + private readonly ProjectGraphFactory _designTimeBuildGraphFactory; internal Task? Test_FileChangesCompletedTask { get; set; } @@ -40,6 +41,12 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null); } + + _designTimeBuildGraphFactory = new ProjectGraphFactory( + _context.RootProjectOptions.Representation, + globalOptions: EvaluationResult.GetGlobalBuildOptions( + context.RootProjectOptions.BuildArguments, + context.EnvironmentOptions)); } public async Task WatchAsync(CancellationToken shutdownCancellationToken) @@ -82,7 +89,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) { var rootProjectOptions = _context.RootProjectOptions; - var buildSucceeded = await BuildProjectAsync(rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); + var buildSucceeded = await BuildProjectAsync(rootProjectOptions.Representation, rootProjectOptions.BuildArguments, iterationCancellationToken); if (!buildSucceeded) { continue; @@ -96,7 +103,10 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single(); // use normalized MSBuild path so that we can index into the ProjectGraph - rootProjectOptions = rootProjectOptions with { ProjectPath = rootProject.ProjectInstance.FullPath }; + rootProjectOptions = rootProjectOptions with + { + Representation = rootProjectOptions.Representation.WithProjectGraphPath(rootProject.ProjectInstance.FullPath) + }; var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; var rootProjectCapabilities = rootProject.GetCapabilities(); @@ -160,7 +170,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.Representation, 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. @@ -332,7 +342,8 @@ void FileChangedCallback(ChangedPath change) var success = true; foreach (var projectPath in projectsToRebuild) { - success = await BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); + // The path of the Workspace Project is the entry-point file path for single-file apps. + success = await BuildProjectAsync(ProjectRepresentation.FromProjectOrEntryPointFilePath(projectPath), rootProjectOptions.BuildArguments, iterationCancellationToken); if (!success) { break; @@ -448,7 +459,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra // additional files/directories may have been added: evaluationResult.WatchFiles(fileWatcher); - await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); + await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.Representation, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -636,8 +647,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 tree containing the root project or entry-point file: + fileWatcher.WatchContainingDirectories([_context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath], includeSubdirectories: true); _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: change => AcceptChange(change), @@ -824,8 +835,7 @@ private async ValueTask EvaluateRootProjectAsync(bool restore, var stopwatch = Stopwatch.StartNew(); var result = EvaluationResult.TryCreate( - _context.RootProjectOptions.ProjectPath, - _context.RootProjectOptions.BuildArguments, + _designTimeBuildGraphFactory, _context.BuildLogger, _context.Options, _context.EnvironmentOptions, @@ -840,7 +850,7 @@ private async ValueTask EvaluateRootProjectAsync(bool restore, } await FileWatcher.WaitForFileChangeAsync( - _context.RootProjectOptions.ProjectPath, + _context.RootProjectOptions.Representation.ProjectOrEntryPointFilePath, _context.Logger, _context.EnvironmentOptions, startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), @@ -848,14 +858,14 @@ await FileWatcher.WaitForFileChangeAsync( } } - private async Task BuildProjectAsync(string projectPath, IReadOnlyList buildArguments, CancellationToken cancellationToken) + private async Task BuildProjectAsync(ProjectRepresentation project, IReadOnlyList buildArguments, CancellationToken cancellationToken) { List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; var processSpec = new ProcessSpec { Executable = _context.EnvironmentOptions.MuxerPath, - WorkingDirectory = Path.GetDirectoryName(projectPath)!, + WorkingDirectory = project.GetContainingDirectory(), IsUserApplication = false, // Capture output if running in a test environment. @@ -871,16 +881,16 @@ private async Task BuildProjectAsync(string projectPath, IReadOnlyList projectInfos; try { - projectInfos = await loader.LoadProjectInfoAsync(rootProjectPath, projectMap, progress: null, msbuildLogger: null, cancellationToken).ConfigureAwait(false); + projectInfos = await loader.LoadProjectInfoAsync(rootProject.ProjectOrEntryPointFilePath, projectMap, progress: null, msbuildLogger: null, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException) { diff --git a/src/BuiltInTools/dotnet-watch/Process/LaunchSettingsProfile.cs b/src/BuiltInTools/dotnet-watch/Process/LaunchSettingsProfile.cs index a1c6397bbf6f..d8fd97441f1c 100644 --- a/src/BuiltInTools/dotnet-watch/Process/LaunchSettingsProfile.cs +++ b/src/BuiltInTools/dotnet-watch/Process/LaunchSettingsProfile.cs @@ -26,30 +26,21 @@ 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 projectDirectory = Path.GetDirectoryName(projectPath); - Debug.Assert(projectDirectory != null); - - var launchSettingsPath = CommonRunHelpers.GetPropertiesLaunchSettingsPath(projectDirectory, "Properties"); - bool hasLaunchSettings = File.Exists(launchSettingsPath); - - var projectNameWithoutExtension = Path.GetFileNameWithoutExtension(projectPath); - var runJsonPath = CommonRunHelpers.GetFlatLaunchSettingsPath(projectDirectory, projectNameWithoutExtension); - bool hasRunJson = File.Exists(runJsonPath); - - if (hasLaunchSettings) + var launchSettingsPath = CommonRunHelpers.TryFindLaunchSettings(project.ProjectOrEntryPointFilePath, launchProfileName, (message, isError) => { - if (hasRunJson) + if (isError) { - logger.LogWarning(CliCommandStrings.RunCommandWarningRunJsonNotUsed, runJsonPath, launchSettingsPath); + logger.LogError(message); } - } - else if (hasRunJson) - { - launchSettingsPath = runJsonPath; - } - else + else + { + logger.LogWarning(message); + } + }); + + if (launchSettingsPath == null) { return null; } diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index fb74333ebdd3..e3e97b3da4f4 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -34,7 +34,7 @@ public EnvironmentOptions EnvironmentOptions RestartOperation restartOperation, CancellationToken cancellationToken) { - var projectNode = projectMap.TryGetProjectNode(projectOptions.ProjectPath, projectOptions.TargetFramework); + var projectNode = projectMap.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, projectOptions.TargetFramework); if (projectNode == null) { // error already reported diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index f67a360a3a32..119d7daa325a 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -1,10 +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 System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.Loader; +using Microsoft.Build.Evaluation; using Microsoft.Build.Locator; +using Microsoft.DotNet.Cli; +using Microsoft.DotNet.Cli.Commands.Run; +using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -92,46 +97,108 @@ public static async Task Main(string[] args) logger.LogDebug("Test flags: {Flags}", environmentOptions.TestFlags); } - if (!TryFindProject(workingDirectory, options, logger, out var projectPath)) + 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); + errorCode = 1; + return null; + } + } + else if (TryFindProject(workingDirectory, options, logger, out var projectPath)) + { + 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.Error_NoProjectsFound, projectPath); + errorCode = 1; return null; } - var rootProjectOptions = options.GetProjectOptions(projectPath, workingDirectory); + var rootProjectOptions = options.GetProjectOptions(project, workingDirectory); errorCode = 0; return new Program(console, loggerFactory, logger, processOutputReporter, rootProjectOptions, options, environmentOptions); } + 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 VirtualProjectBuildingCommand.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.GetFullPath(Path.Combine(workingDirectory, projectPath)); + } + catch (Exception e) { - projectPath = Path.Combine(searchBase, projectPath); + 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(); + string[] projects; + try + { + projects = Directory.GetFiles(projectPath, "*.*proj"); + } + catch (Exception e) + { + logger.LogError(Resources.The_specified_path_0_is_invalid_1, projectPath, e.Message); + return false; + } - if (projects.Count > 1) + if (projects.Length > 1) { logger.LogError(Resources.Error_MultipleProjectsFound, projectPath); return false; } - if (projects.Count == 0) + if (projects.Length == 0) { - logger.LogError(Resources.Error_NoProjectsFound, projectPath); + if (options.ProjectPath != null) + { + logger.LogError(Resources.Error_NoProjectsFound, projectPath); + } + return false; } @@ -139,6 +206,8 @@ private static bool TryFindProject(string searchBase, CommandLineOptions options return true; } + Debug.Assert(options.ProjectPath != null); + if (!File.Exists(projectPath)) { logger.LogError(Resources.Error_ProjectPath_NotFound, projectPath); @@ -185,6 +254,12 @@ internal async Task RunAsync() if (options.List) { + if (rootProjectOptions.Representation.EntryPointFilePath != null) + { + logger.LogError("--list does not support file-based programs"); + return 1; + } + return await ListFilesAsync(processRunner, shutdownCancellationToken); } @@ -200,6 +275,11 @@ internal async Task RunAsync() var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); await watcher.WatchAsync(shutdownCancellationToken); } + else if (rootProjectOptions.Representation.EntryPointFilePath != null) + { + logger.LogError("File-based programs are only supported when Hot Reload is enabled"); + return 1; + } else { await DotNetWatcher.WatchAsync(context, shutdownCancellationToken); @@ -264,10 +344,13 @@ private bool IsHotReloadEnabled() private async Task ListFilesAsync(ProcessRunner processRunner, CancellationToken cancellationToken) { + // file-based programs are not supported with --list + Debug.Assert(rootProjectOptions.Representation.PhysicalPath != null); + var buildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName); var fileSetFactory = new MSBuildFileSetFactory( - rootProjectOptions.ProjectPath, + rootProjectOptions.Representation.PhysicalPath, rootProjectOptions.BuildArguments, processRunner, new BuildReporter(buildLogger, options.GlobalOptions, environmentOptions)); diff --git a/src/BuiltInTools/dotnet-watch/Resources.resx b/src/BuiltInTools/dotnet-watch/Resources.resx index 5688add72027..048a2e13a023 100644 --- a/src/BuiltInTools/dotnet-watch/Resources.resx +++ b/src/BuiltInTools/dotnet-watch/Resources.resx @@ -120,6 +120,9 @@ 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"} @@ -128,9 +131,8 @@ Could not find a MSBuild project file in '{0}'. Specify which project to use with the --project option. {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 ec3b10c8b235..92520d6be92b 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs @@ -36,11 +36,16 @@ public BuildEvaluator(DotNetWatchContext context) } protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() - => new( - _context.RootProjectOptions.ProjectPath, + { + // file-based programs only supported in Hot Reload mode: + Debug.Assert(_context.RootProjectOptions.Representation.PhysicalPath != null); + + return new( + _context.RootProjectOptions.Representation.PhysicalPath, _context.RootProjectOptions.BuildArguments, _context.ProcessRunner, new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions)); + } public IReadOnlyList GetProcessArguments(int iteration) { diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 6de60206c1d2..e3e3305af699 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -27,9 +27,14 @@ internal class MSBuildFileSetFactory( 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( + new ProjectRepresentation(rootProjectFile, entryPointFilePath: null), + globalOptions: CommandLineOptions.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)); + internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) { public readonly IReadOnlyDictionary Files = files; @@ -124,10 +129,7 @@ void AddFile(string filePath, string? staticWebAssetPath) ProjectGraph? projectGraph = null; if (requireProjectGraph != null) { - var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) - .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value); - - projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(rootProjectFile, globalOptions, Logger, requireProjectGraph.Value, cancellationToken); + projectGraph = _buildGraphFactory.TryLoadProjectGraph(Logger, requireProjectGraph.Value, cancellationToken); if (projectGraph == null && requireProjectGraph == true) { return null; diff --git a/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf b/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf index 1d99d3ff575c..9afc617d3c20 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.cs.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..914d325f1be0 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.de.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..0824496c1e29 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.es.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..1801be33200c 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.fr.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..ddab5ebffd63 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.it.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..ab802458a2e6 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ja.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. 複数の MSBuild プロジェクト ファイルが '{0}' で見つかりました。使用するものを --project オプションで指定してください。 @@ -17,11 +22,6 @@ プロジェクト ファイル '{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..d8683a79ea52 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ko.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. '{0}'에서 여러 MSBuild 프로젝트 파일을 찾았습니다. --project 옵션을 사용하여 사용할 파일을 지정하세요. @@ -17,11 +22,6 @@ 프로젝트 파일 '{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..7b976df8add6 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.pl.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..de9060ff8810 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.pt-BR.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..a5d8d5edfe4a 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.ru.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. В "{0}" обнаружено несколько файлов проекта MSBuild. Укажите файл, который нужно использовать, с помощью параметра --project. @@ -17,11 +22,6 @@ Файл проекта "{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..5a7c9d5cf039 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.tr.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + 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. @@ -17,11 +22,6 @@ 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..6466b6735292 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hans.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. 在“{0}”中找到多个 MSBuild 项目文件。请指定要将哪一个文件用于 --project 选项。 @@ -17,11 +22,6 @@ 项目文件“{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..fcbb3a5798c8 100644 --- a/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf +++ b/src/BuiltInTools/dotnet-watch/xlf/Resources.zh-Hant.xlf @@ -2,6 +2,11 @@ + + Cannot specify both '{0}' and '{1}' options. + Cannot specify both '{0}' and '{1}' options. + + Multiple MSBuild project files found in '{0}'. Specify which to use with the --project option. 在 '{0}' 中找到多個 MSBuild 專案檔。請指定要搭配 --project 選項使用的專案檔。 @@ -17,11 +22,6 @@ 專案檔 '{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/dotnet/Commands/Run/CommonRunHelpers.cs b/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs index 64d2b8a1075a..0769c1f804c2 100644 --- a/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs +++ b/src/Cli/dotnet/Commands/Run/CommonRunHelpers.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.Diagnostics; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Cli.Commands.Run; @@ -27,6 +28,50 @@ public static string GetPropertiesLaunchSettingsPath(string directoryPath, strin public static string GetFlatLaunchSettingsPath(string directoryPath, string projectNameWithoutExtension) => Path.Join(directoryPath, $"{projectNameWithoutExtension}.run.json"); + public static string? TryFindLaunchSettings(string projectOrEntryPointFilePath, string? launchProfile, Action report) + { + var buildPathContainer = Path.GetDirectoryName(projectOrEntryPointFilePath); + Debug.Assert(buildPathContainer != null); + + // VB.NET projects store the launch settings file in the + // "My Project" directory instead of a "Properties" directory. + // TODO: use the `AppDesignerFolder` MSBuild property instead, which captures this logic already + var propsDirectory = string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".vbproj", StringComparison.OrdinalIgnoreCase) + ? "My Project" + : "Properties"; + + string launchSettingsPath = GetPropertiesLaunchSettingsPath(buildPathContainer, propsDirectory); + bool hasLaunchSetttings = File.Exists(launchSettingsPath); + + string appName = Path.GetFileNameWithoutExtension(projectOrEntryPointFilePath); + string runJsonPath = GetFlatLaunchSettingsPath(buildPathContainer, appName); + bool hasRunJson = File.Exists(runJsonPath); + + if (hasLaunchSetttings) + { + if (hasRunJson) + { + report(string.Format(CliCommandStrings.RunCommandWarningRunJsonNotUsed, runJsonPath, launchSettingsPath), false); + } + + return launchSettingsPath; + } + + if (hasRunJson) + { + return runJsonPath; + } + + if (!string.IsNullOrEmpty(launchProfile)) + { + report(string.Format(CliCommandStrings.RunCommandExceptionCouldNotLocateALaunchSettingsFile, launchProfile, $""" + {launchSettingsPath} + {runJsonPath} + """), true); + } + + return null; + } /// /// Applies adjustments to MSBuild arguments to better suit LLM/agentic environments, if such an environment is detected. diff --git a/src/Cli/dotnet/Commands/Run/RunCommand.cs b/src/Cli/dotnet/Commands/Run/RunCommand.cs index 9c11228ab213..9a96c19a5afb 100644 --- a/src/Cli/dotnet/Commands/Run/RunCommand.cs +++ b/src/Cli/dotnet/Commands/Run/RunCommand.cs @@ -216,7 +216,13 @@ internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel return true; } - var launchSettingsPath = ReadCodeFromStdin ? null : TryFindLaunchSettings(projectOrEntryPointFilePath: ProjectFileFullPath ?? EntryPointFileFullPath!, launchProfile: LaunchProfile); + var launchSettingsPath = ReadCodeFromStdin + ? null + : CommonRunHelpers.TryFindLaunchSettings( + projectOrEntryPointFilePath: ProjectFileFullPath ?? EntryPointFileFullPath!, + launchProfile: LaunchProfile, + static (message, isError) => (isError ? Reporter.Error : Reporter.Output).WriteLine(message)); + if (launchSettingsPath is null) { return true; @@ -249,57 +255,6 @@ internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel } return true; - - static string? TryFindLaunchSettings(string projectOrEntryPointFilePath, string? launchProfile) - { - var buildPathContainer = Path.GetDirectoryName(projectOrEntryPointFilePath)!; - - string propsDirectory; - - // VB.NET projects store the launch settings file in the - // "My Project" directory instead of a "Properties" directory. - // TODO: use the `AppDesignerFolder` MSBuild property instead, which captures this logic already - if (string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".vbproj", StringComparison.OrdinalIgnoreCase)) - { - propsDirectory = "My Project"; - } - else - { - propsDirectory = "Properties"; - } - - string launchSettingsPath = CommonRunHelpers.GetPropertiesLaunchSettingsPath(buildPathContainer, propsDirectory); - bool hasLaunchSetttings = File.Exists(launchSettingsPath); - - string appName = Path.GetFileNameWithoutExtension(projectOrEntryPointFilePath); - string runJsonPath = CommonRunHelpers.GetFlatLaunchSettingsPath(buildPathContainer, appName); - bool hasRunJson = File.Exists(runJsonPath); - - if (hasLaunchSetttings) - { - if (hasRunJson) - { - Reporter.Output.WriteLine(string.Format(CliCommandStrings.RunCommandWarningRunJsonNotUsed, runJsonPath, launchSettingsPath).Yellow()); - } - - return launchSettingsPath; - } - - if (hasRunJson) - { - return runJsonPath; - } - - if (!string.IsNullOrEmpty(launchProfile)) - { - Reporter.Error.WriteLine(string.Format(CliCommandStrings.RunCommandExceptionCouldNotLocateALaunchSettingsFile, launchProfile, $""" - {launchSettingsPath} - {runJsonPath} - """).Bold().Red()); - } - - return null; - } } private void EnsureProjectIsBuilt(out Func? projectFactory, out RunProperties? cachedRunProperties, out VirtualProjectBuildingCommand? virtualCommand) diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 7a4ff1295838..5e3513f457d7 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -1047,6 +1047,9 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection return CreateProjectInstance(projectCollection, addGlobalProperties: null); } + public static string GetVirtualProjectPath(string entryPointFilePath) + => Path.ChangeExtension(entryPointFilePath, ".csproj"); + private ProjectInstance CreateProjectInstance( ProjectCollection projectCollection, Action>? addGlobalProperties) @@ -1068,7 +1071,6 @@ private ProjectInstance CreateProjectInstance( ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) { - var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj"); var projectFileWriter = new StringWriter(); WriteProjectFile( projectFileWriter, @@ -1082,7 +1084,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) using var reader = new StringReader(projectFileText); using var xmlReader = XmlReader.Create(reader); var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); - projectRoot.FullPath = projectFileFullPath; + projectRoot.FullPath = GetVirtualProjectPath(EntryPointFileFullPath); return projectRoot; } } diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 9ac5030c4eba..33f6a7bd014f 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -14,6 +14,7 @@ public async Task ReferenceOutputAssembly_False() var workingDirectory = testAsset.Path; var hostDir = Path.Combine(testAsset.Path, "Host"); var hostProject = Path.Combine(hostDir, "Host.csproj"); + var hostProjectDef = new ProjectRepresentation(hostProject, entryPointFilePath: null); var options = TestOptions.GetProjectOptions(["--project", hostProject]); @@ -24,10 +25,11 @@ public async Task ReferenceOutputAssembly_False() var reporter = new TestReporter(Logger); var loggerFactory = new LoggerFactory(reporter); var logger = loggerFactory.CreateLogger("Test"); - var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(options.ProjectPath, globalOptions: [], logger, projectGraphRequired: false, CancellationToken.None); + var factory = new ProjectGraphFactory(hostProjectDef, globalOptions: []); + var projectGraph = factory.TryLoadProjectGraph(logger, projectGraphRequired: false, CancellationToken.None); var handler = new CompilationHandler(logger, processRunner); - await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); + await handler.Workspace.UpdateProjectConeAsync(hostProjectDef, 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/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);