From aea7240ef535f19d8199a7308a08be7badea2bdc Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 18 Feb 2025 15:20:36 +0100 Subject: [PATCH 01/15] Add basic support for 'dotnet run file.cs' --- .../MSBuildForwardingAppWithoutLogging.cs | 18 +- .../commands/VirtualProjectBuildingCommand.cs | 193 +++++++ .../dotnet-run/LocalizableStrings.resx | 6 + .../dotnet/commands/dotnet-run/RunCommand.cs | 231 ++++++-- .../dotnet-run/xlf/LocalizableStrings.cs.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.de.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.es.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.fr.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.it.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.ja.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.ko.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.pl.xlf | 10 + .../xlf/LocalizableStrings.pt-BR.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.ru.xlf | 10 + .../dotnet-run/xlf/LocalizableStrings.tr.xlf | 10 + .../xlf/LocalizableStrings.zh-Hans.xlf | 10 + .../xlf/LocalizableStrings.zh-Hant.xlf | 10 + src/Cli/dotnet/dotnet.csproj | 1 + test/dotnet-run.Tests/RunFileTests.cs | 540 ++++++++++++++++++ test/dotnet.Tests/dotnet.Tests.csproj | 1 + 20 files changed, 1050 insertions(+), 70 deletions(-) create mode 100644 src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs create mode 100644 test/dotnet-run.Tests/RunFileTests.cs diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs index 36515612598d..ced954231415 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs @@ -41,13 +41,7 @@ public static string MSBuildVersion // True if, given current state of the class, MSBuild would be executed in its own process. public bool ExecuteMSBuildOutOfProc => _forwardingApp != null; - private readonly Dictionary _msbuildRequiredEnvironmentVariables = - new() - { - { "MSBuildExtensionsPath", MSBuildExtensionsPathTestHook ?? AppContext.BaseDirectory }, - { "MSBuildSDKsPath", GetMSBuildSDKsPath() }, - { "DOTNET_HOST_PATH", GetDotnetPath() }, - }; + private readonly Dictionary _msbuildRequiredEnvironmentVariables = GetMSBuildRequiredEnvironmentVariables(); private readonly List _msbuildRequiredParameters = [ "-maxcpucount", "-verbosity:m" ]; @@ -205,6 +199,16 @@ private static string GetDotnetPath() return new Muxer().MuxerPath; } + internal static Dictionary GetMSBuildRequiredEnvironmentVariables() + { + return new() + { + { "MSBuildExtensionsPath", MSBuildExtensionsPathTestHook ?? AppContext.BaseDirectory }, + { "MSBuildSDKsPath", GetMSBuildSDKsPath() }, + { "DOTNET_HOST_PATH", GetDotnetPath() }, + }; + } + private static bool IsRestoreSources(string arg) { return arg.StartsWith("/p:RestoreSources=", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs new file mode 100644 index 000000000000..6a416590cb4e --- /dev/null +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Xml; +using Microsoft.Build.Construction; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; +using Microsoft.DotNet.Cli.Utils; + +namespace Microsoft.DotNet.Tools; + +/// +/// Used to build a virtual project file in memory to support dotnet run file.cs. +/// +internal sealed class VirtualProjectBuildingCommand +{ + public Dictionary GlobalProperties { get; } = new(StringComparer.OrdinalIgnoreCase); + public required string EntryPointFileFullPath { get; init; } + + public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) + { + var binaryLogger = GetBinaryLogger(binaryLoggerArgs); + var consoleLogger = new ConsoleLogger(verbosity); + Dictionary savedEnvironmentVariables = new(); + try + { + // Set environment variables. + foreach (var (key, value) in MSBuildForwardingAppWithoutLogging.GetMSBuildRequiredEnvironmentVariables()) + { + savedEnvironmentVariables[key] = Environment.GetEnvironmentVariable(key); + Environment.SetEnvironmentVariable(key, value); + } + + // Setup MSBuild. + ReadOnlySpan binaryLoggers = binaryLogger is null ? [] : [binaryLogger]; + var projectCollection = new ProjectCollection( + GlobalProperties, + [.. binaryLoggers, consoleLogger], + ToolsetDefinitionLocations.Default); + var parameters = new BuildParameters(projectCollection) + { + Loggers = projectCollection.Loggers, + LogTaskInputs = binaryLogger is not null, + }; + BuildManager.DefaultBuildManager.BeginBuild(parameters); + + // Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`). + // See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838. + var restoreRequest = new BuildRequestData( + CreateProjectInstance(projectCollection, addGlobalProperties: static (globalProperties) => + { + globalProperties["MSBuildRestoreSessionId"] = Guid.NewGuid().ToString("D"); + globalProperties["MSBuildIsRestoring"] = bool.TrueString; + }), + targetsToBuild: ["Restore"], + hostServices: null, + BuildRequestDataFlags.ClearCachesAfterBuild | BuildRequestDataFlags.SkipNonexistentTargets | BuildRequestDataFlags.IgnoreMissingEmptyAndInvalidImports | BuildRequestDataFlags.FailOnUnresolvedSdk); + var restoreResult = BuildManager.DefaultBuildManager.BuildRequest(restoreRequest); + if (restoreResult.OverallResult != BuildResultCode.Success) + { + return 1; + } + + // Then do a build. + var buildRequest = new BuildRequestData( + CreateProjectInstance(projectCollection), + targetsToBuild: ["Build"]); + var buildResult = BuildManager.DefaultBuildManager.BuildRequest(buildRequest); + if (buildResult.OverallResult != BuildResultCode.Success) + { + return 1; + } + + BuildManager.DefaultBuildManager.EndBuild(); + return 0; + } + catch (Exception e) + { + Console.Error.WriteLine(e.Message); + return 1; + } + finally + { + foreach (var (key, value) in savedEnvironmentVariables) + { + Environment.SetEnvironmentVariable(key, value); + } + + binaryLogger?.Shutdown(); + consoleLogger.Shutdown(); + } + + static ILogger? GetBinaryLogger(string[] args) + { + for (int i = args.Length - 1; i >= 0; i--) + { + var arg = args[i]; + if (arg.StartsWith("/bl:") || arg.Equals("/bl") + || arg.StartsWith("--binaryLogger:") || arg.Equals("--binaryLogger") + || arg.StartsWith("-bl:") || arg.Equals("-bl")) + { + return new BinaryLogger + { + Parameters = arg.IndexOf(':') is >= 0 and var index + ? arg[(index + 1)..] + : "msbuild.binlog", + }; + } + } + + return null; + } + } + + public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection) + { + return CreateProjectInstance(projectCollection, addGlobalProperties: null); + } + + private ProjectInstance CreateProjectInstance( + ProjectCollection projectCollection, + Action>? addGlobalProperties = null) + { + var projectRoot = CreateProjectRootElement(projectCollection); + + var globalProperties = projectCollection.GlobalProperties; + if (addGlobalProperties is not null) + { + globalProperties = new Dictionary(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase); + addGlobalProperties(globalProperties); + } + + return ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions + { + GlobalProperties = globalProperties, + }); + } + + private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) + { + var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj"); + var projectFileText = """ + + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" /> + + + + + + + + """; + ProjectRootElement projectRoot; + using (var xmlReader = XmlReader.Create(new StringReader(projectFileText))) + { + projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); + } + projectRoot.FullPath = projectFileFullPath; + return projectRoot; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx b/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx index 7835c0542477..3c08999c0918 100644 --- a/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx +++ b/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx @@ -244,4 +244,10 @@ Make the profile names distinct. A launch profile with the name '{0}' doesn't exist. + + Cannot run a file without top-level statements and without a project: '{0}' + + + The option '{0}' is not supported when running a file without a project: '{1}' + diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index e9598d7b1643..8eb65e41c57d 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -1,12 +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.Reflection; +using System.Diagnostics; using Microsoft.Build.Evaluation; using Microsoft.Build.Exceptions; using Microsoft.Build.Execution; using Microsoft.Build.Framework; using Microsoft.Build.Logging; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; using Microsoft.DotNet.Cli; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.CommandFactory; @@ -19,8 +22,24 @@ public partial class RunCommand private record RunProperties(string? RunCommand, string? RunArguments, string? RunWorkingDirectory); public bool NoBuild { get; } + + /// + /// Value of the --project option. + /// public string? ProjectFileOrDirectory { get; } - public string ProjectFileFullPath { get; } + + /// + /// Full path to a project file to run. + /// if running without a project file + /// (then is not ). + /// + public string? ProjectFileFullPath { get; } + + /// + /// Full path to an entry-point .cs file to run without a project file. + /// + public string? EntryPointFileFullPath { get; } + public string[] Args { get; set; } public bool NoRestore { get; } public VerbosityOptions? Verbosity { get; } @@ -57,7 +76,8 @@ public RunCommand( { NoBuild = noBuild; ProjectFileOrDirectory = projectFileOrDirectory; - ProjectFileFullPath = DiscoverProjectFilePath(projectFileOrDirectory); + ProjectFileFullPath = DiscoverProjectFilePath(projectFileOrDirectory, ref args, out string? entryPointFileFullPath); + EntryPointFileFullPath = entryPointFileFullPath; LaunchProfile = launchProfile; NoLaunchProfile = noLaunchProfile; NoLaunchProfileArguments = noLaunchProfileArguments; @@ -76,6 +96,7 @@ public int Execute() return 1; } + Func? projectFactory = null; if (ShouldBuild) { if (string.Equals("true", launchSettings?.DotNetRunMessages, StringComparison.OrdinalIgnoreCase)) @@ -83,12 +104,16 @@ public int Execute() Reporter.Output.WriteLine(LocalizableStrings.RunCommandBuilding); } - EnsureProjectIsBuilt(); + EnsureProjectIsBuilt(out projectFactory); + } + else if (EntryPointFileFullPath is not null) + { + throw new GracefulException(string.Format(LocalizableStrings.RunFileUnsupportedSwitch, RunCommandParser.NoBuildOption.Name, EntryPointFileFullPath)); } try { - ICommand targetCommand = GetTargetCommand(); + ICommand targetCommand = GetTargetCommand(projectFactory); ApplyLaunchSettingsProfileToCommand(targetCommand, launchSettings); // Env variables specified on command line override those specified in launch profile: @@ -145,6 +170,17 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? return true; } + if (ProjectFileFullPath is null) + { + if (!string.IsNullOrEmpty(LaunchProfile)) + { + Reporter.Error.WriteLine(string.Format(LocalizableStrings.RunFileUnsupportedSwitch, RunCommandParser.LaunchProfileOption.Name, EntryPointFileFullPath).Bold().Red()); + return false; + } + + return true; + } + var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath); if (!File.Exists(launchSettingsPath)) { @@ -211,14 +247,41 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? } } - private void EnsureProjectIsBuilt() + private void EnsureProjectIsBuilt(out Func? projectFactory) { - var buildResult = - new RestoringCommand( + int buildResult; + if (EntryPointFileFullPath is not null) + { + var command = new VirtualProjectBuildingCommand + { + EntryPointFileFullPath = EntryPointFileFullPath, + }; + + AddUserPassedProperties(command.GlobalProperties, RestoreArgs); + + projectFactory = command.CreateProjectInstance; + buildResult = command.Execute( + binaryLoggerArgs: RestoreArgs, + verbosity: Verbosity switch + { + null => Interactive ? LoggerVerbosity.Minimal : LoggerVerbosity.Quiet, + VerbosityOptions.quiet | VerbosityOptions.q => LoggerVerbosity.Quiet, + VerbosityOptions.minimal | VerbosityOptions.m => LoggerVerbosity.Minimal, + VerbosityOptions.normal | VerbosityOptions.n => LoggerVerbosity.Normal, + VerbosityOptions.detailed | VerbosityOptions.d => LoggerVerbosity.Detailed, + VerbosityOptions.diagnostic | VerbosityOptions.diag => LoggerVerbosity.Diagnostic, + _ => throw new Exception($"Unexpected verbosity '{Verbosity}'"), + }); + } + else + { + projectFactory = null; + buildResult = new RestoringCommand( RestoreArgs.Prepend(ProjectFileFullPath), NoRestore, advertiseWorkloadUpdates: false ).Execute(); + } if (buildResult != 0) { @@ -247,10 +310,10 @@ private string[] GetRestoreArguments(IEnumerable cliRestoreArgs) return args.ToArray(); } - private ICommand GetTargetCommand() + private ICommand GetTargetCommand(Func? projectFactory) { FacadeLogger? logger = DetermineBinlogger(RestoreArgs); - var project = EvaluateProject(ProjectFileFullPath, RestoreArgs, logger); + var project = EvaluateProject(ProjectFileFullPath, projectFactory, RestoreArgs, logger); ValidatePreconditions(project); InvokeRunArgumentsTarget(project, RestoreArgs, Verbosity, logger); logger?.ReallyShutdown(); @@ -258,8 +321,10 @@ private ICommand GetTargetCommand() var command = CreateCommandFromRunProperties(project, runProperties); return command; - static ProjectInstance EvaluateProject(string projectFilePath, string[] restoreArgs, ILogger? binaryLogger) + static ProjectInstance EvaluateProject(string? projectFilePath, Func? projectFactory, string[] restoreArgs, ILogger? binaryLogger) { + Debug.Assert(projectFilePath is not null || projectFactory is not null); + var globalProperties = new Dictionary(StringComparer.OrdinalIgnoreCase) { // This property disables default item globbing to improve performance @@ -268,16 +333,12 @@ static ProjectInstance EvaluateProject(string projectFilePath, string[] restoreA { Constants.MSBuildExtensionsPath, AppContext.BaseDirectory } }; - var userPassedProperties = DeriveUserPassedProperties(restoreArgs); - if (userPassedProperties is not null) - { - foreach (var (key, values) in userPassedProperties) - { - globalProperties[key] = string.Join(";", values); - } - } + AddUserPassedProperties(globalProperties, restoreArgs); + var collection = new ProjectCollection(globalProperties: globalProperties, loggers: binaryLogger is null ? null : [binaryLogger], toolsetDefinitionLocations: ToolsetDefinitionLocations.Default); - return collection.LoadProject(projectFilePath).CreateProjectInstance(); + return projectFilePath is not null + ? collection.LoadProject(projectFilePath).CreateProjectInstance() + : projectFactory(collection); } static void ValidatePreconditions(ProjectInstance project) @@ -288,35 +349,6 @@ static void ValidatePreconditions(ProjectInstance project) } } - static Dictionary>? DeriveUserPassedProperties(string[] args) - { - var fakeCommand = new System.CommandLine.CliCommand("dotnet") { CommonOptions.PropertiesOption }; - var propertyParsingConfiguration = new System.CommandLine.CliConfiguration(fakeCommand); - var propertyParseResult = propertyParsingConfiguration.Parse(args); - var propertyValues = propertyParseResult.GetValue(CommonOptions.PropertiesOption); - - if (propertyValues != null) - { - var userPassedProperties = new Dictionary>(propertyValues.Length, StringComparer.OrdinalIgnoreCase); - foreach (var property in propertyValues) - { - foreach (var (key, value) in MSBuildPropertyParser.ParseProperties(property)) - { - if (userPassedProperties.TryGetValue(key, out var existingValues)) - { - existingValues.Add(value); - } - else - { - userPassedProperties[key] = [value]; - } - } - } - return userPassedProperties; - } - return null; - } - static RunProperties ReadRunPropertiesFromProject(ProjectInstance project, string[] applicationArgs) { string runProgram = project.GetPropertyValue("RunCommand"); @@ -417,6 +449,35 @@ static FacadeLogger ConfigureDispatcher(List binaryLoggers) } } + /// + /// Should have . + /// + private static void AddUserPassedProperties(Dictionary globalProperties, string[] args) + { + var fakeCommand = new System.CommandLine.CliCommand("dotnet") { CommonOptions.PropertiesOption }; + var propertyParsingConfiguration = new System.CommandLine.CliConfiguration(fakeCommand); + var propertyParseResult = propertyParsingConfiguration.Parse(args); + var propertyValues = propertyParseResult.GetValue(CommonOptions.PropertiesOption); + + if (propertyValues != null) + { + foreach (var property in propertyValues) + { + foreach (var (key, value) in MSBuildPropertyParser.ParseProperties(property)) + { + if (globalProperties.TryGetValue(key, out var existingValues)) + { + globalProperties[key] = existingValues + ";" + value; + } + else + { + globalProperties[key] = value; + } + } + } + } + } + /// /// This class acts as a wrapper around the BinaryLogger, to allow us to keep the BinaryLogger alive across multiple phases of the build. /// The methods here are stubs so that the real binarylogger sees that we support these functionalities. @@ -545,34 +606,78 @@ private static void ThrowUnableToRunError(ProjectInstance project) project.GetPropertyValue("OutputType"))); } - private string DiscoverProjectFilePath(string? projectFileOrDirectoryPath) + private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, ref string[] args, out string? entryPointFilePath) { - if (string.IsNullOrWhiteSpace(projectFileOrDirectoryPath)) + bool emptyProjectOption = string.IsNullOrWhiteSpace(projectFileOrDirectoryPath); + if (emptyProjectOption) { projectFileOrDirectoryPath = Directory.GetCurrentDirectory(); } - if (Directory.Exists(projectFileOrDirectoryPath)) + string? projectFilePath = Directory.Exists(projectFileOrDirectoryPath) + ? TryFindSingleProjectInDirectory(projectFileOrDirectoryPath) + : projectFileOrDirectoryPath; + + // If no project exists in the directory and no --project was given, + // try to resolve an entry-point file instead. + entryPointFilePath = projectFilePath is null && emptyProjectOption + ? TryFindEntryPointFilePath(ref args) + : null; + + if (entryPointFilePath is null && projectFilePath is null) { - projectFileOrDirectoryPath = FindSingleProjectInDirectory(projectFileOrDirectoryPath); + throw new GracefulException(LocalizableStrings.RunCommandExceptionNoProjects, projectFileOrDirectoryPath, "--project"); } - return projectFileOrDirectoryPath; - } - public static string FindSingleProjectInDirectory(string directory) - { - string[] projectFiles = Directory.GetFiles(directory, "*.*proj"); + return projectFilePath; - if (projectFiles.Length == 0) + static string? TryFindSingleProjectInDirectory(string directory) { - throw new GracefulException(LocalizableStrings.RunCommandExceptionNoProjects, directory, "--project"); + string[] projectFiles = Directory.GetFiles(directory, "*.*proj"); + + if (projectFiles.Length == 0) + { + return null; + } + + if (projectFiles.Length > 1) + { + throw new GracefulException(LocalizableStrings.RunCommandExceptionMultipleProjects, directory); + } + + return projectFiles[0]; + } + + static string? TryFindEntryPointFilePath(ref string[] args) + { + if (args is not [var arg, ..] || + string.IsNullOrWhiteSpace(arg) || + !arg.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || + !File.Exists(arg)) + { + return null; + } + + if (!HasTopLevelStatements(arg)) + { + throw new GracefulException(LocalizableStrings.NoTopLevelStatements, arg); + } + + args = args[1..]; + return Path.GetFullPath(arg); } - else if (projectFiles.Length > 1) + + static bool HasTopLevelStatements(string entryPointFilePath) { - throw new GracefulException(LocalizableStrings.RunCommandExceptionMultipleProjects, directory); + var tree = ParseCSharp(entryPointFilePath); + return tree.GetRoot().ChildNodes().OfType().Any(); } - return projectFiles[0]; + static CSharpSyntaxTree ParseCSharp(string filePath) + { + using var stream = File.OpenRead(filePath); + return (CSharpSyntaxTree)CSharpSyntaxTree.ParseText(SourceText.From(stream, Encoding.UTF8), path: filePath); + } } } } diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf index 59ada52ff3af..17ae5bfc7000 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf @@ -51,6 +51,11 @@ Nastavte odlišné názvy profilů. Profil spuštění s názvem {0} neexistuje. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Pomocí možnosti -p je možné v jednu chvíli zadat pouze jeden projekt. @@ -151,6 +156,11 @@ Aktuální {1} je {2}. {0} není platný soubor projektu. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Použití nastavení spuštění z {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf index ecec47ebfcae..bbc22cb4c613 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf @@ -51,6 +51,11 @@ Erstellen Sie eindeutige Profilnamen. Es ist kein Startprofil mit dem Namen "{0}" vorhanden. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Nur jeweils ein Projekt kann mithilfe der Option „-p“ angegeben werden. @@ -151,6 +156,11 @@ Ein ausführbares Projekt muss ein ausführbares TFM (z. B. net5.0) und den Outp "{0}" ist keine gültige Projektdatei. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Die Starteinstellungen von {0} werden verwendet… diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf index 63695e47e069..e167c9795524 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf @@ -51,6 +51,11 @@ Defina nombres de perfiles distintos. No existe ningún perfil de inicio con el nombre "{0}". + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Solo se puede especificar un proyecto a la vez mediante la opción -p. @@ -151,6 +156,11 @@ El valor actual de {1} es "{2}". "{0}" no es un archivo de proyecto válido. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Usando la configuración de inicio de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf index 286a7a541634..8cb14f379765 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf @@ -51,6 +51,11 @@ faites en sorte que les noms de profil soient distincts. Un profil de lancement avec le nom '{0}' n'existe pas. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Vous ne pouvez spécifier qu’un seul projet à la fois à l’aide de l’option -p. @@ -151,6 +156,11 @@ Le {1} actuel est '{2}'. '{0}' n'est pas un fichier projet valide. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Utilisation des paramètres de lancement à partir de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf index 5f52c5e48fae..343b0853e689 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf @@ -51,6 +51,11 @@ Rendi distinti i nomi dei profili. Non esiste un profilo di avvio con il nome '{0}'. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. È possibile specificare un solo progetto alla volta utilizzando l'opzione -p. @@ -151,6 +156,11 @@ Il valore corrente di {1} è '{2}'. '{0}' non è un file di progetto valido. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Uso delle impostazioni di avvio di {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf index ab29ec8d15c3..e0af6283f429 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. '{0} ' という名前の起動プロファイルは存在しません。 + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. -p オプションを使用して一度に指定できるプロジェクトは 1 つだけです。 @@ -151,6 +156,11 @@ The current {1} is '{2}'. '{0}' は有効なプロジェクト ファイルではありません。 + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... {0} からの起動設定を使用中... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf index a2b77c1bc8bf..6ddbbd813513 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. 이름이 '{0}'인 시작 프로필이 없습니다. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. -p 옵션을 사용하여 한 번에 하나의 프로젝트만 지정할 수 있습니다. @@ -151,6 +156,11 @@ The current {1} is '{2}'. '{0}'은(는) 유효한 프로젝트 파일이 아닙니다. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... {0}의 시작 설정을 사용하는 중... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf index d013b5a9a62f..c5d35f0a2dfd 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf @@ -51,6 +51,11 @@ Rozróżnij nazwy profilów. Profil uruchamiania o nazwie „{0}” nie istnieje. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Jednocześnie można określić tylko jeden projekt przy użyciu opcji -p. @@ -151,6 +156,11 @@ Bieżący element {1}: „{2}”. „{0}” nie jest prawidłowym plikiem projektu. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Używanie ustawień uruchamiania z profilu {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf index 258d2670d7e7..7d62b6fccdf2 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf @@ -51,6 +51,11 @@ Diferencie os nomes dos perfis. Um perfil de lançamento com o nome '{0}' não existe. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. Somente um projeto pode ser especificado por vez usando a opção -p. @@ -151,6 +156,11 @@ O {1} atual é '{2}'. '{0}' não é um arquivo de projeto válido. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Usando as configurações de inicialização de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf index 78de4b5ebcd1..83c3ac0de774 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. Профиль запуска с именем "{0}" не существует. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. С помощью параметра -p можно указать только один проект за раз. @@ -151,6 +156,11 @@ The current {1} is '{2}'. "{0}" не является допустимым файлом проекта. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... Используются параметры запуска из {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf index 167786e6277a..3a8080e67926 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. '{0}' adlı bir başlatma profili yok. + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. -p seçeneği kullanılarak tek seferde yalnızca bir proje belirtilebilir. @@ -151,6 +156,11 @@ Geçerli {1}: '{2}'. '{0}' geçerli bir proje dosyası değil. + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... {0} içindeki başlatma ayarları kullanılıyor... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf index 799e30f616a5..e0a3cb9a959b 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. 名为“{0}”的启动配置文件不存在。 + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. 使用 -p 选项时一次只能指定一个项目。 @@ -151,6 +156,11 @@ The current {1} is '{2}'. “{0}”不是有效的项目文件。 + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... 从 {0} 使用启动设置... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf index cec07d6b8f89..5234deadcd55 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf @@ -51,6 +51,11 @@ Make the profile names distinct. 名稱為 '{0}' 的啟動設定檔不存在。 + + Cannot run a file without top-level statements and without a project: '{0}' + Cannot run a file without top-level statements and without a project: '{0}' + + Only one project can be specified at a time using the -p option. 使用 -p 選項時,一次只能指定一個專案。 @@ -151,6 +156,11 @@ The current {1} is '{2}'. '{0}' 並非有效的專案名稱。 + + The option '{0}' is not supported when running a file without a project: '{1}' + The option '{0}' is not supported when running a file without a project: '{1}' + + Using launch settings from {0}... 使用來自 {0} 的啟動設定... diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index bebcb6cd9462..64de8d3b31a7 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -114,6 +114,7 @@ + diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs new file mode 100644 index 000000000000..6bda895b7cf8 --- /dev/null +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -0,0 +1,540 @@ +// 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.Tools.Run; + +namespace Microsoft.DotNet.Cli.Run.Tests; + +public sealed class RunFileTests(ITestOutputHelper log) : SdkTest(log) +{ + private static readonly string s_program = """ + if (args.Length > 0) + { + Console.WriteLine("echo args:" + string.Join(";", args)); + } + Console.WriteLine("Hello from " + System.Reflection.Assembly.GetExecutingAssembly().GetName().Name); + #if !DEBUG + Console.WriteLine("Release config"); + #endif + """; + + private static readonly string s_programDependingOnUtil = """ + if (args.Length > 0) + { + Console.WriteLine("echo args:" + string.Join(";", args)); + } + Console.WriteLine("Hello, " + Util.GetMessage()); + """; + + private static readonly string s_util = """ + static class Util + { + public static string GetMessage() + { + return "String from Util"; + } + } + """; + + private static readonly string s_consoleProject = """ + + + Exe + net10.0 + enable + + + """; + + private static readonly string s_runCommandExceptionNoProjects = + "Couldn't find a project to run."; + + private static readonly string s_noTopLevelStatements = + "Cannot run a file without top-level statements and without a project:"; + + /// + /// dotnet run file.cs succeeds without a project file. + /// + [Theory] + [InlineData(null)] // will be replaced with an absolute path + [InlineData("Program.cs")] + [InlineData("./Program.cs")] + [InlineData("Program.CS")] + public void FilePath(string? path) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + + File.WriteAllText(programPath, s_program); + + path ??= programPath; + + new DotnetCommand(Log, "run", path) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + } + + /// + /// Casing of the argument is used for the output binary name. + /// + [Fact] + public void FilePath_DifferentCasing() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + new DotnetCommand(Log, "run", "program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from program"); + } + + /// + /// dotnet run folder/file.cs succeeds without a project file. + /// + [Fact] + public void FilePath_OutsideWorkDir() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + var dirName = Path.GetFileName(testInstance.Path); + + new DotnetCommand(Log, "run", $"{dirName}/Program.cs") + .WithWorkingDirectory(Path.GetDirectoryName(testInstance.Path)!) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + } + + /// + /// dotnet run --project file.cs fails. + /// + [Fact] + public void FilePath_AsProjectArgument() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "--project", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(LocalizableStrings.RunCommandException); + } + + /// + /// dotnet run folder without a project file is not supported. + /// + [Theory] + [InlineData(null)] // will be replaced with an absolute path + [InlineData(".")] + [InlineData("../MSBuildTestApp")] + [InlineData("../MSBuildTestApp/")] + public void FolderPath(string? path) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + path ??= testInstance.Path; + + new DotnetCommand(Log, "run", path) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// dotnet run app.csproj fails if app.csproj does not exist. + /// + [Fact] + public void ProjectPath_DoesNotExist() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "./App.csproj") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// dotnet run app.csproj where app.csproj exists + /// runs the project and passes 'app.csproj' as an argument. + /// + [Fact] + public void ProjectPath_Exists() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "./App.csproj") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:./App.csproj + Hello from App + """); + } + + /// + /// Only .cs files can be run without a project file, + /// others fall back to normal dotnet run behavior. + /// + [Theory] + [InlineData("Program")] + [InlineData("Program.csx")] + [InlineData("Program.vb")] + public void NonCsFileExtension(string fileName) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, fileName), s_program); + + new DotnetCommand(Log, "run", fileName) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// The build fails when there are multiple files with entry points. + /// + [Fact] + public void MultipleEntryPoints() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "Program2.cs"), s_program); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(LocalizableStrings.RunCommandException); + } + + /// + /// When the entry-point file does not exist, fallback to normal dotnet run behavior. + /// + [Fact] + public void NoCode() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// Cannot run a non-entry-point file. + /// + [Fact] + public void ClassLibrary_EntryPointFileExists() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), s_util); + + new DotnetCommand(Log, "run", "Util.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_noTopLevelStatements); + } + + /// + /// When the entry-point file does not exist, fallback to normal dotnet run behavior. + /// + [Fact] + public void ClassLibrary_EntryPointFileDoesNotExist() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), s_util); + + new DotnetCommand(Log, "run", "NonExistentFile.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// Other files in the folder are part of the compilation. + /// + [Fact] + public void MultipleFiles_RunEntryPoint() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_programDependingOnUtil); + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), s_util); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, String from Util"); + } + + /// + /// dotnet run util.cs fails if util.cs is not the entry-point. + /// + [Fact] + public void MultipleFiles_RunLibraryFile() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_programDependingOnUtil); + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), s_util); + + new DotnetCommand(Log, "run", "Util.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_noTopLevelStatements); + } + + /// + /// If there are nested project files like + /// + /// app/file.cs + /// app/nested/x.csproj + /// app/nested/another.cs + /// + /// executing dotnet run app/file.cs will include the nested .cs file in the compilation. + /// Hence we could consider reporting an error in this situation. + /// However, the same problem exists for normal builds with explicit project files + /// and usually the build fails because there are multiple entry points or other clashes. + /// + [Fact] + public void NestedProjectFiles() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + Directory.CreateDirectory(Path.Join(testInstance.Path, "nested")); + File.WriteAllText(Path.Join(testInstance.Path, "nested", "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + } + + /// + /// dotnet run folder/app.csproj -> the argument is not recognized as an entry-point file + /// (it does not have .cs file extension), so this fallbacks to normal dotnet run behavior. + /// + [Fact] + public void RunNestedProjectFile() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + var dirName = Path.GetFileName(testInstance.Path); + + new DotnetCommand(Log, "run", $"{dirName}/App.csproj") + .WithWorkingDirectory(Path.GetDirectoryName(testInstance.Path)!) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// Only top-level statements are supported for now; Main method is not. + /// + [Fact] + public void MainMethod() + { + var testInstance = _testAssetsManager.CopyTestAsset("MSBuildTestApp").WithSource(); + File.Delete(Path.Join(testInstance.Path, "MSBuildTestApp.csproj")); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_noTopLevelStatements); + } + + /// + /// Empty file does not contain top-level statements, so that's an error. + /// + [Fact] + public void EmptyFile() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), string.Empty); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_noTopLevelStatements); + } + + /// + /// Implicit build files have an effect. + /// + [Fact] + public void DirectoryBuildProps() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ + + + TestName + + + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from TestName"); + } + + /// + /// Command-line arguments should be passed through. + /// + [Theory] + [InlineData("other;args", "other;args")] + [InlineData("--;other;args", "other;args")] + [InlineData("--appArg", "--appArg")] + [InlineData("-c;Debug;--xyz", "--xyz")] + public void Arguments_PassThrough(string input, string output) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, ["run", "Program.cs", .. input.Split(';')]) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut($""" + echo args:{output} + Hello from Program + """); + } + + /// + /// dotnet run --unknown-arg file.cs fallbacks to normal dotnet run behavior. + /// + [Fact] + public void Arguments_Unrecognized() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, ["run", "--arg", "Program.cs"]) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + + /// + /// dotnet run --some-known-arg file.cs is supported. + /// + [Theory, CombinatorialData] + public void Arguments_Recognized(bool beforeFile) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + string[] args = beforeFile + ? ["run", "-c", "Release", "Program.cs", "more", "args"] + : ["run", "Program.cs", "-c", "Release", "more", "args"]; + + new DotnetCommand(Log, args) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:more;args + Hello from Program + Release config + """); + } + + /// + /// Some arguments of dotnet run are not supported without a project. + /// + [Theory, CombinatorialData] + public void Arguments_Unsupported( + bool beforeFile, + [CombinatorialValues("--launch-profile;test", "--no-build")] + string input) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + string[] innerArgs = input.Split(';'); + string[] args = beforeFile + ? ["run", .. innerArgs, "Program.cs"] + : ["run", "Program.cs", .. innerArgs]; + + new DotnetCommand(Log, args) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining($"The option '{innerArgs[0]}' is not supported when running a file without a project:"); + } + + /// + /// dotnet run --bl file.cs produces a binary log. + /// + [Theory, CombinatorialData] + public void BinaryLog(bool beforeFile) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + string[] args = beforeFile + ? ["run", "-bl", "Program.cs"] + : ["run", "Program.cs", "-bl"]; + + new DotnetCommand(Log, args) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + new DirectoryInfo(testInstance.Path) + .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) + .Select(f => f.Name) + .Should().BeEquivalentTo(["msbuild.binlog", "msbuild-dotnet-run.binlog"]); + } + + /// + /// dotnet run file.cs should not produce a binary log. + /// + [Fact] + public void BinaryLog_NotSpecified() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + new DirectoryInfo(testInstance.Path) + .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) + .Select(f => f.Name) + .Should().BeEmpty(); + } +} diff --git a/test/dotnet.Tests/dotnet.Tests.csproj b/test/dotnet.Tests/dotnet.Tests.csproj index 58062b2df585..f9dd11f6a241 100644 --- a/test/dotnet.Tests/dotnet.Tests.csproj +++ b/test/dotnet.Tests/dotnet.Tests.csproj @@ -123,6 +123,7 @@ + From 8094d4938812d7ddf2a6b971d70b747fbeb3a1e6 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 18 Feb 2025 18:09:11 +0100 Subject: [PATCH 02/15] Fix tests on case sensitive OSes --- test/dotnet-run.Tests/RunFileTests.cs | 54 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index 6bda895b7cf8..ff9a511a755d 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -52,15 +52,24 @@ public static string GetMessage() private static readonly string s_noTopLevelStatements = "Cannot run a file without top-level statements and without a project:"; + private static bool HasCaseInsensitiveFileSystem + { + get + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + } + /// /// dotnet run file.cs succeeds without a project file. /// [Theory] - [InlineData(null)] // will be replaced with an absolute path - [InlineData("Program.cs")] - [InlineData("./Program.cs")] - [InlineData("Program.CS")] - public void FilePath(string? path) + [InlineData(null, false)] // will be replaced with an absolute path + [InlineData("Program.cs", false)] + [InlineData("./Program.cs", false)] + [InlineData("Program.CS", true)] + public void FilePath(string? path, bool differentCasing) { var testInstance = _testAssetsManager.CreateTestDirectory(); @@ -70,11 +79,20 @@ public void FilePath(string? path) path ??= programPath; - new DotnetCommand(Log, "run", path) + var result = new DotnetCommand(Log, "run", path) .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut("Hello from Program"); + .Execute(); + + if (!differentCasing || HasCaseInsensitiveFileSystem) + { + result.Should().Pass() + .And.HaveStdOut("Hello from Program"); + } + else + { + result.Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } } /// @@ -85,11 +103,21 @@ public void FilePath_DifferentCasing() { var testInstance = _testAssetsManager.CreateTestDirectory(); File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); - new DotnetCommand(Log, "run", "program.cs") + + var result = new DotnetCommand(Log, "run", "program.cs") .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut("Hello from program"); + .Execute(); + + if (HasCaseInsensitiveFileSystem) + { + result.Should().Pass() + .And.HaveStdOut("Hello from program"); + } + else + { + result.Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } } /// From 9f51756b6d01bea20a608dfe1c645656475d0f5d Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 20 Feb 2025 09:53:30 +0100 Subject: [PATCH 03/15] Share binlog arg detection code --- .../commands/VirtualProjectBuildingCommand.cs | 5 ++--- src/Cli/dotnet/commands/dotnet-run/Program.cs | 13 +++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index 6a416590cb4e..ed8c67a23e59 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -11,6 +11,7 @@ using Microsoft.Build.Framework; using Microsoft.Build.Logging; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Tools.Run; namespace Microsoft.DotNet.Tools; @@ -100,9 +101,7 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) for (int i = args.Length - 1; i >= 0; i--) { var arg = args[i]; - if (arg.StartsWith("/bl:") || arg.Equals("/bl") - || arg.StartsWith("--binaryLogger:") || arg.Equals("--binaryLogger") - || arg.StartsWith("-bl:") || arg.Equals("-bl")) + if (RunCommand.IsBinLogArgument(arg)) { return new BinaryLogger { diff --git a/src/Cli/dotnet/commands/dotnet-run/Program.cs b/src/Cli/dotnet/commands/dotnet-run/Program.cs index 1dee09851a17..e4f706b988f4 100644 --- a/src/Cli/dotnet/commands/dotnet-run/Program.cs +++ b/src/Cli/dotnet/commands/dotnet-run/Program.cs @@ -17,6 +17,14 @@ public static RunCommand FromArgs(string[] args) return FromParseResult(parseResult); } + internal static bool IsBinLogArgument(string arg) + { + const StringComparison comp = StringComparison.Ordinal; + return arg.StartsWith("/bl:", comp) || arg.Equals("/bl", comp) + || arg.StartsWith("--binaryLogger:", comp) || arg.Equals("--binaryLogger", comp) + || arg.StartsWith("-bl:", comp) || arg.Equals("-bl", comp); + } + public static RunCommand FromParseResult(ParseResult parseResult) { if (parseResult.UsingRunCommandShorthandProjectOption()) @@ -35,10 +43,7 @@ public static RunCommand FromParseResult(ParseResult parseResult) var nonBinLogArgs = new List(); foreach (var arg in applicationArguments) { - - if (arg.StartsWith("/bl:") || arg.Equals("/bl") - || arg.StartsWith("--binaryLogger:") || arg.Equals("--binaryLogger") - || arg.StartsWith("-bl:") || arg.Equals("-bl")) + if (IsBinLogArgument(arg)) { binlogArgs.Add(arg); } From 5a5206f094e554ec2ef896aaea74ef091b8adcff Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 20 Feb 2025 10:02:11 +0100 Subject: [PATCH 04/15] Nullable-enable RunCommand.cs --- .../dotnet/commands/dotnet-run/RunCommand.cs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index 8eb65e41c57d..29c53cb8ac39 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.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. +#nullable enable + using System.Diagnostics; using Microsoft.Build.Evaluation; using Microsoft.Build.Exceptions; @@ -275,6 +277,8 @@ private void EnsureProjectIsBuilt(out Func? } else { + Debug.Assert(ProjectFileFullPath is not null); + projectFactory = null; buildResult = new RestoringCommand( RestoreArgs.Prepend(ProjectFileFullPath), @@ -336,9 +340,14 @@ static ProjectInstance EvaluateProject(string? projectFilePath, Func Date: Fri, 21 Feb 2025 14:43:54 +0100 Subject: [PATCH 05/15] Test embedded resource --- test/dotnet-run.Tests/RunFileTests.cs | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index ff9a511a755d..f8958e3f5f69 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -565,4 +565,33 @@ public void BinaryLog_NotSpecified() .Select(f => f.Name) .Should().BeEmpty(); } + + /// + /// Default projects include and compile also non-C# files, like resources. + /// + [Fact] + public void EmbeddedResource() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + using var stream = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream("Program.Resources.resources")!; + using var reader = new System.Resources.ResourceReader(stream); + Console.WriteLine(reader.Cast().Single()); + """); + File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), """ + + + TestValue + + + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + [MyString, TestValue] + """); + } } From fe91ade0f78b7857bcf14eeae165a3f481543a9d Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 13:54:15 +0100 Subject: [PATCH 06/15] Allow `--no-build` --- .../dotnet/commands/dotnet-run/RunCommand.cs | 5 ++- test/dotnet-run.Tests/RunFileTests.cs | 40 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index 29c53cb8ac39..f41eece2591b 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -110,7 +110,10 @@ public int Execute() } else if (EntryPointFileFullPath is not null) { - throw new GracefulException(string.Format(LocalizableStrings.RunFileUnsupportedSwitch, RunCommandParser.NoBuildOption.Name, EntryPointFileFullPath)); + projectFactory = new VirtualProjectBuildingCommand + { + EntryPointFileFullPath = EntryPointFileFullPath, + }.CreateProjectInstance; } try diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index f8958e3f5f69..f6e69d5e8d91 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -502,7 +502,7 @@ Release config [Theory, CombinatorialData] public void Arguments_Unsupported( bool beforeFile, - [CombinatorialValues("--launch-profile;test", "--no-build")] + [CombinatorialValues("--launch-profile;test")] string input) { var testInstance = _testAssetsManager.CreateTestDirectory(); @@ -594,4 +594,42 @@ public void EmbeddedResource() [MyString, TestValue] """); } + + [Fact] + public void NoBuild() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var programFile = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programFile, s_program); + + // It is an error when never built before. + new DotnetCommand(Log, "run", "--no-build", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining("The system cannot find the file specified"); + + // Now build it. + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + // Changing the program has no effect when it is not built. + File.WriteAllText(programFile, """Console.WriteLine("Changed");"""); + new DotnetCommand(Log, "run", "--no-build", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + // The change has an effect when built again. + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Changed"); + + } } From 5c873d1455d3ea28a897e78163c1f9492522a488 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 14:15:30 +0100 Subject: [PATCH 07/15] Allow `--launch-profile` --- .../dotnet-run/LocalizableStrings.resx | 3 - .../dotnet/commands/dotnet-run/RunCommand.cs | 19 +---- .../dotnet-run/xlf/LocalizableStrings.cs.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.de.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.es.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.fr.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.it.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.ja.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.ko.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.pl.xlf | 5 -- .../xlf/LocalizableStrings.pt-BR.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.ru.xlf | 5 -- .../dotnet-run/xlf/LocalizableStrings.tr.xlf | 5 -- .../xlf/LocalizableStrings.zh-Hans.xlf | 5 -- .../xlf/LocalizableStrings.zh-Hant.xlf | 5 -- test/dotnet-run.Tests/RunFileTests.cs | 81 +++++++++++++------ 16 files changed, 61 insertions(+), 107 deletions(-) diff --git a/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx b/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx index 3c08999c0918..79325816a555 100644 --- a/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx +++ b/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx @@ -247,7 +247,4 @@ Make the profile names distinct. Cannot run a file without top-level statements and without a project: '{0}' - - The option '{0}' is not supported when running a file without a project: '{1}' - diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index f41eece2591b..4340b2588009 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -175,18 +175,7 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? return true; } - if (ProjectFileFullPath is null) - { - if (!string.IsNullOrEmpty(LaunchProfile)) - { - Reporter.Error.WriteLine(string.Format(LocalizableStrings.RunFileUnsupportedSwitch, RunCommandParser.LaunchProfileOption.Name, EntryPointFileFullPath).Bold().Red()); - return false; - } - - return true; - } - - var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath); + var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!); if (!File.Exists(launchSettingsPath)) { if (!string.IsNullOrEmpty(LaunchProfile)) @@ -225,9 +214,9 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? return true; - static string? TryFindLaunchSettings(string projectFilePath) + static string? TryFindLaunchSettings(string projectOrEntryPointFilePath) { - var buildPathContainer = File.Exists(projectFilePath) ? Path.GetDirectoryName(projectFilePath) : projectFilePath; + var buildPathContainer = File.Exists(projectOrEntryPointFilePath) ? Path.GetDirectoryName(projectOrEntryPointFilePath) : projectOrEntryPointFilePath; if (buildPathContainer is null) { return null; @@ -238,7 +227,7 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? // 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(projectFilePath), ".vbproj", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(Path.GetExtension(projectOrEntryPointFilePath), ".vbproj", StringComparison.OrdinalIgnoreCase)) { propsDirectory = "My Project"; } diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf index 17ae5bfc7000..abfc7d427c45 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.cs.xlf @@ -156,11 +156,6 @@ Aktuální {1} je {2}. {0} není platný soubor projektu. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Použití nastavení spuštění z {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf index bbc22cb4c613..0536b262adc6 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.de.xlf @@ -156,11 +156,6 @@ Ein ausführbares Projekt muss ein ausführbares TFM (z. B. net5.0) und den Outp "{0}" ist keine gültige Projektdatei. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Die Starteinstellungen von {0} werden verwendet… diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf index e167c9795524..6493f1225514 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.es.xlf @@ -156,11 +156,6 @@ El valor actual de {1} es "{2}". "{0}" no es un archivo de proyecto válido. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Usando la configuración de inicio de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf index 8cb14f379765..ba6804b350aa 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.fr.xlf @@ -156,11 +156,6 @@ Le {1} actuel est '{2}'. '{0}' n'est pas un fichier projet valide. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Utilisation des paramètres de lancement à partir de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf index 343b0853e689..824c4eec596d 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.it.xlf @@ -156,11 +156,6 @@ Il valore corrente di {1} è '{2}'. '{0}' non è un file di progetto valido. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Uso delle impostazioni di avvio di {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf index e0af6283f429..2b85677d654a 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ja.xlf @@ -156,11 +156,6 @@ The current {1} is '{2}'. '{0}' は有効なプロジェクト ファイルではありません。 - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... {0} からの起動設定を使用中... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf index 6ddbbd813513..f6763e33f8a2 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ko.xlf @@ -156,11 +156,6 @@ The current {1} is '{2}'. '{0}'은(는) 유효한 프로젝트 파일이 아닙니다. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... {0}의 시작 설정을 사용하는 중... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf index c5d35f0a2dfd..b2caf7058159 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pl.xlf @@ -156,11 +156,6 @@ Bieżący element {1}: „{2}”. „{0}” nie jest prawidłowym plikiem projektu. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Używanie ustawień uruchamiania z profilu {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf index 7d62b6fccdf2..cf2710bf8fe8 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.pt-BR.xlf @@ -156,11 +156,6 @@ O {1} atual é '{2}'. '{0}' não é um arquivo de projeto válido. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Usando as configurações de inicialização de {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf index 83c3ac0de774..54cdd26340fb 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.ru.xlf @@ -156,11 +156,6 @@ The current {1} is '{2}'. "{0}" не является допустимым файлом проекта. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... Используются параметры запуска из {0}... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf index 3a8080e67926..6ee59dd32733 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.tr.xlf @@ -156,11 +156,6 @@ Geçerli {1}: '{2}'. '{0}' geçerli bir proje dosyası değil. - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... {0} içindeki başlatma ayarları kullanılıyor... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf index e0a3cb9a959b..0f404a00327a 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hans.xlf @@ -156,11 +156,6 @@ The current {1} is '{2}'. “{0}”不是有效的项目文件。 - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... 从 {0} 使用启动设置... diff --git a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf index 5234deadcd55..47feeab54b82 100644 --- a/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/commands/dotnet-run/xlf/LocalizableStrings.zh-Hant.xlf @@ -156,11 +156,6 @@ The current {1} is '{2}'. '{0}' 並非有效的專案名稱。 - - The option '{0}' is not supported when running a file without a project: '{1}' - The option '{0}' is not supported when running a file without a project: '{1}' - - Using launch settings from {0}... 使用來自 {0} 的啟動設定... diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index f6e69d5e8d91..2cf14b350a29 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -46,6 +46,25 @@ public static string GetMessage() """; + private static readonly string s_launchSettings = """ + { + "profiles": { + "TestProfile1": { + "commandName": "Project", + "environmentVariables": { + "Message": "TestProfileMessage1" + } + }, + "TestProfile2": { + "commandName": "Project", + "environmentVariables": { + "Message": "TestProfileMessage2" + } + } + } + } + """; + private static readonly string s_runCommandExceptionNoProjects = "Couldn't find a project to run."; @@ -496,30 +515,6 @@ Release config """); } - /// - /// Some arguments of dotnet run are not supported without a project. - /// - [Theory, CombinatorialData] - public void Arguments_Unsupported( - bool beforeFile, - [CombinatorialValues("--launch-profile;test")] - string input) - { - var testInstance = _testAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); - - string[] innerArgs = input.Split(';'); - string[] args = beforeFile - ? ["run", .. innerArgs, "Program.cs"] - : ["run", "Program.cs", .. innerArgs]; - - new DotnetCommand(Log, args) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdErrContaining($"The option '{innerArgs[0]}' is not supported when running a file without a project:"); - } - /// /// dotnet run --bl file.cs produces a binary log. /// @@ -630,6 +625,44 @@ public void NoBuild() .Execute() .Should().Pass() .And.HaveStdOut("Changed"); + } + + [Fact] + public void LaunchProfile() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program + """ + + Console.WriteLine($"Message: '{Environment.GetEnvironmentVariable("Message")}'"); + """); + Directory.CreateDirectory(Path.Join(testInstance.Path, "Properties")); + File.WriteAllText(Path.Join(testInstance.Path, "Properties", "launchSettings.json"), s_launchSettings); + + new DotnetCommand(Log, "run", "--no-launch-profile", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + Hello from Program + Message: '' + """); + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining(""" + Hello from Program + Message: 'TestProfileMessage1' + """); + + new DotnetCommand(Log, "run", "-lp", "TestProfile2", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining(""" + Hello from Program + Message: 'TestProfileMessage2' + """); } } From da30f3efa2380e385debd8692a0c14372a64a230 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 17:38:37 +0100 Subject: [PATCH 08/15] Improve binlog option parsing --- .../commands/VirtualProjectBuildingCommand.cs | 32 ++++----- src/Cli/dotnet/commands/dotnet-run/Program.cs | 2 +- .../dotnet/commands/dotnet-run/RunCommand.cs | 8 ++- test/dotnet-run.Tests/RunFileTests.cs | 69 +++++++++++++++++++ 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index ed8c67a23e59..16710803fbec 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -3,6 +3,7 @@ #nullable enable +using System.Collections.Immutable; using System.Xml; using Microsoft.Build.Construction; using Microsoft.Build.Definition; @@ -25,7 +26,7 @@ internal sealed class VirtualProjectBuildingCommand public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) { - var binaryLogger = GetBinaryLogger(binaryLoggerArgs); + var binaryLoggers = GetBinaryLoggers(binaryLoggerArgs); var consoleLogger = new ConsoleLogger(verbosity); Dictionary savedEnvironmentVariables = new(); try @@ -38,7 +39,6 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) } // Setup MSBuild. - ReadOnlySpan binaryLoggers = binaryLogger is null ? [] : [binaryLogger]; var projectCollection = new ProjectCollection( GlobalProperties, [.. binaryLoggers, consoleLogger], @@ -46,7 +46,7 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) var parameters = new BuildParameters(projectCollection) { Loggers = projectCollection.Loggers, - LogTaskInputs = binaryLogger is not null, + LogTaskInputs = binaryLoggers.Length != 0, }; BuildManager.DefaultBuildManager.BeginBuild(parameters); @@ -92,27 +92,25 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) Environment.SetEnvironmentVariable(key, value); } - binaryLogger?.Shutdown(); + foreach (var binaryLogger in binaryLoggers) + { + binaryLogger.Shutdown(); + } + consoleLogger.Shutdown(); } - static ILogger? GetBinaryLogger(string[] args) + static ImmutableArray GetBinaryLoggers(string[] args) { - for (int i = args.Length - 1; i >= 0; i--) - { - var arg = args[i]; - if (RunCommand.IsBinLogArgument(arg)) + return args + .Where(RunCommand.IsBinLogArgument) + .Select(static ILogger (arg) => new BinaryLogger { - return new BinaryLogger - { - Parameters = arg.IndexOf(':') is >= 0 and var index + Parameters = arg.IndexOf(':') is >= 0 and var index ? arg[(index + 1)..] : "msbuild.binlog", - }; - } - } - - return null; + }) + .ToImmutableArray(); } } diff --git a/src/Cli/dotnet/commands/dotnet-run/Program.cs b/src/Cli/dotnet/commands/dotnet-run/Program.cs index e4f706b988f4..7fb5562296de 100644 --- a/src/Cli/dotnet/commands/dotnet-run/Program.cs +++ b/src/Cli/dotnet/commands/dotnet-run/Program.cs @@ -19,7 +19,7 @@ public static RunCommand FromArgs(string[] args) internal static bool IsBinLogArgument(string arg) { - const StringComparison comp = StringComparison.Ordinal; + const StringComparison comp = StringComparison.OrdinalIgnoreCase; return arg.StartsWith("/bl:", comp) || arg.Equals("/bl", comp) || arg.StartsWith("--binaryLogger:", comp) || arg.Equals("--binaryLogger", comp) || arg.StartsWith("-bl:", comp) || arg.Equals("-bl", comp); diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index 4340b2588009..8c26000a2006 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -406,11 +406,15 @@ static void InvokeRunArgumentsTarget(ProjectInstance project, string[] restoreAr static FacadeLogger? DetermineBinlogger(string[] restoreArgs) { - List binaryLoggers = new(); - foreach (var blArg in restoreArgs.Where(arg => arg.StartsWith("-bl", StringComparison.OrdinalIgnoreCase))) + foreach (var blArg in restoreArgs) { + if (!IsBinLogArgument(blArg)) + { + continue; + } + if (blArg.Contains(':')) { // split and forward args diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index 2cf14b350a29..89bed40ae227 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -540,6 +540,75 @@ public void BinaryLog(bool beforeFile) .Should().BeEquivalentTo(["msbuild.binlog", "msbuild-dotnet-run.binlog"]); } + [Theory] + [InlineData("-bl")] + [InlineData("-BL")] + [InlineData("-bl:msbuild.binlog")] + [InlineData("/bl")] + [InlineData("/bl:msbuild.binlog")] + [InlineData("--binaryLogger")] + [InlineData("--binaryLogger:msbuild.binlog")] + [InlineData("-bl:another.binlog")] + public void BinaryLog_ArgumentForms(string arg) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "Program.cs", arg) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + var fileName = arg.Split(':', 2) is [_, { Length: > 0 } value] ? Path.GetFileNameWithoutExtension(value) : "msbuild"; + + new DirectoryInfo(testInstance.Path) + .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) + .Select(f => f.Name) + .Should().BeEquivalentTo([$"{fileName}.binlog", $"{fileName}-dotnet-run.binlog"]); + } + + [Fact] + public void BinaryLog_Multiple() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "Program.cs", "-bl:one.binlog", "two.binlog", "/bl:three.binlog") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:two.binlog + Hello from Program + """); + + new DirectoryInfo(testInstance.Path) + .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) + .Select(f => f.Name) + .Should().BeEquivalentTo(["one.binlog", "one-dotnet-run.binlog", "three.binlog", "three-dotnet-run.binlog"]); + } + + [Fact] + public void BinaryLog_WrongExtension() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, "run", "Program.cs", "-bl:test.test") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(""" + Invalid binary logger parameter(s): "test.test" + """); + + new DirectoryInfo(testInstance.Path) + .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) + .Select(f => f.Name) + .Should().BeEmpty(); + } + /// /// dotnet run file.cs should not produce a binary log. /// From fcd9a9224c91132226206ebd15922e42fb3ed483 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 17:39:37 +0100 Subject: [PATCH 09/15] Improve code --- src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs | 2 +- src/Cli/dotnet/commands/dotnet-run/RunCommand.cs | 5 +++-- test/dotnet-run.Tests/RunFileTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index 16710803fbec..f9205e518cbc 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -121,7 +121,7 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection private ProjectInstance CreateProjectInstance( ProjectCollection projectCollection, - Action>? addGlobalProperties = null) + Action>? addGlobalProperties) { var projectRoot = CreateProjectRootElement(projectCollection); diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index 8c26000a2006..abfb687192c1 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -459,6 +459,8 @@ static FacadeLogger ConfigureDispatcher(List binaryLoggers) /// private static void AddUserPassedProperties(Dictionary globalProperties, string[] args) { + Debug.Assert(globalProperties.Comparer == StringComparer.OrdinalIgnoreCase); + var fakeCommand = new System.CommandLine.CliCommand("dotnet") { CommonOptions.PropertiesOption }; var propertyParsingConfiguration = new System.CommandLine.CliConfiguration(fakeCommand); var propertyParseResult = propertyParsingConfiguration.Parse(args); @@ -656,8 +658,7 @@ private static void ThrowUnableToRunError(ProjectInstance project) static string? TryFindEntryPointFilePath(ref string[] args) { - if (args is not [var arg, ..] || - string.IsNullOrWhiteSpace(arg) || + if (args is not [{ } arg, ..] || !arg.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || !File.Exists(arg)) { diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index 89bed40ae227..123f045480b9 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -36,11 +36,11 @@ public static string GetMessage() } """; - private static readonly string s_consoleProject = """ + private static readonly string s_consoleProject = $""" Exe - net10.0 + {ToolsetInfo.CurrentTargetFramework} enable From eb2a1e8326b08cf3be7eff1b46b7d7e9f7c1b4db Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 19:09:35 +0100 Subject: [PATCH 10/15] Fix test to work on Linux --- test/dotnet-run.Tests/RunFileTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index 123f045480b9..b89050dde4c7 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -671,7 +671,7 @@ public void NoBuild() .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining("The system cannot find the file specified"); + .And.HaveStdErrContaining("An error occurred trying to start process"); // Now build it. new DotnetCommand(Log, "run", "Program.cs") From d08a1be33279f25662881e2989bf792fb5204922 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 27 Feb 2025 19:14:45 +0100 Subject: [PATCH 11/15] Link related issues --- src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index f9205e518cbc..e0f3782499ee 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -51,7 +51,8 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) BuildManager.DefaultBuildManager.BeginBuild(parameters); // Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`). - // See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838. + // See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838 + // and https://github.com/dotnet/msbuild/issues/11519. var restoreRequest = new BuildRequestData( CreateProjectInstance(projectCollection, addGlobalProperties: static (globalProperties) => { @@ -154,7 +155,10 @@ private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCol - + Date: Wed, 5 Mar 2025 11:15:38 +0100 Subject: [PATCH 12/15] Fix more binary logger inconsistencies --- .../commands/VirtualProjectBuildingCommand.cs | 33 ++++++++++--------- .../dotnet/commands/dotnet-run/RunCommand.cs | 6 +++- test/dotnet-run.Tests/RunFileTests.cs | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index e0f3782499ee..22f1484a2716 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -3,7 +3,6 @@ #nullable enable -using System.Collections.Immutable; using System.Xml; using Microsoft.Build.Construction; using Microsoft.Build.Definition; @@ -26,7 +25,7 @@ internal sealed class VirtualProjectBuildingCommand public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) { - var binaryLoggers = GetBinaryLoggers(binaryLoggerArgs); + var binaryLogger = GetBinaryLogger(binaryLoggerArgs); var consoleLogger = new ConsoleLogger(verbosity); Dictionary savedEnvironmentVariables = new(); try @@ -38,7 +37,8 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) Environment.SetEnvironmentVariable(key, value); } - // Setup MSBuild. + // Set up MSBuild. + ReadOnlySpan binaryLoggers = binaryLogger is null ? [] : [binaryLogger]; var projectCollection = new ProjectCollection( GlobalProperties, [.. binaryLoggers, consoleLogger], @@ -93,25 +93,28 @@ public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) Environment.SetEnvironmentVariable(key, value); } - foreach (var binaryLogger in binaryLoggers) - { - binaryLogger.Shutdown(); - } - + binaryLogger?.Shutdown(); consoleLogger.Shutdown(); } - static ImmutableArray GetBinaryLoggers(string[] args) + static ILogger? GetBinaryLogger(string[] args) { - return args - .Where(RunCommand.IsBinLogArgument) - .Select(static ILogger (arg) => new BinaryLogger + // Like in MSBuild, only the last binary logger is used. + for (int i = args.Length - 1; i >= 0; i--) + { + var arg = args[i]; + if (RunCommand.IsBinLogArgument(arg)) { - Parameters = arg.IndexOf(':') is >= 0 and var index + return new BinaryLogger + { + Parameters = arg.IndexOf(':') is >= 0 and var index ? arg[(index + 1)..] : "msbuild.binlog", - }) - .ToImmutableArray(); + }; + } + } + + return null; } } diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index abfb687192c1..e419b3e878fa 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -408,8 +408,9 @@ static void InvokeRunArgumentsTarget(ProjectInstance project, string[] restoreAr { List binaryLoggers = new(); - foreach (var blArg in restoreArgs) + for (int i = restoreArgs.Length - 1; i >= 0; i--) { + string blArg = restoreArgs[i]; if (!IsBinLogArgument(blArg)) { continue; @@ -433,6 +434,9 @@ static void InvokeRunArgumentsTarget(ProjectInstance project, string[] restoreAr var filename = "msbuild-dotnet-run" + ".binlog"; binaryLoggers.Add(new BinaryLogger { Parameters = filename }); } + + // Like in MSBuild, only the last binary logger is used. + break; } // this binaryLogger needs to be used for both evaluation and execution, so we need to only call it with a single IEventSource across diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index b89050dde4c7..b8a70dcb92e1 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -586,7 +586,7 @@ Hello from Program new DirectoryInfo(testInstance.Path) .EnumerateFiles("*.binlog", SearchOption.TopDirectoryOnly) .Select(f => f.Name) - .Should().BeEquivalentTo(["one.binlog", "one-dotnet-run.binlog", "three.binlog", "three-dotnet-run.binlog"]); + .Should().BeEquivalentTo(["three.binlog", "three-dotnet-run.binlog"]); } [Fact] From 9b8054672cd3d0c70e464f929c98a0128aa0c0e7 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 4 Mar 2025 13:53:46 +0100 Subject: [PATCH 13/15] Reuse terminal logger creation function --- .../commands/VirtualProjectBuildingCommand.cs | 3 +-- .../dotnet/commands/dotnet-run/RunCommand.cs | 23 ++++++++----------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index 22f1484a2716..ef2875b49bb2 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -23,10 +23,9 @@ internal sealed class VirtualProjectBuildingCommand public Dictionary GlobalProperties { get; } = new(StringComparer.OrdinalIgnoreCase); public required string EntryPointFileFullPath { get; init; } - public int Execute(string[] binaryLoggerArgs, LoggerVerbosity verbosity) + public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger) { var binaryLogger = GetBinaryLogger(binaryLoggerArgs); - var consoleLogger = new ConsoleLogger(verbosity); Dictionary savedEnvironmentVariables = new(); try { diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index e419b3e878fa..aca68fa4ccdd 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -256,16 +256,7 @@ private void EnsureProjectIsBuilt(out Func? projectFactory = command.CreateProjectInstance; buildResult = command.Execute( binaryLoggerArgs: RestoreArgs, - verbosity: Verbosity switch - { - null => Interactive ? LoggerVerbosity.Minimal : LoggerVerbosity.Quiet, - VerbosityOptions.quiet | VerbosityOptions.q => LoggerVerbosity.Quiet, - VerbosityOptions.minimal | VerbosityOptions.m => LoggerVerbosity.Minimal, - VerbosityOptions.normal | VerbosityOptions.n => LoggerVerbosity.Normal, - VerbosityOptions.detailed | VerbosityOptions.d => LoggerVerbosity.Detailed, - VerbosityOptions.diagnostic | VerbosityOptions.diag => LoggerVerbosity.Diagnostic, - _ => throw new Exception($"Unexpected verbosity '{Verbosity}'"), - }); + consoleLogger: MakeTerminalLogger(Verbosity ?? GetDefaultVerbosity())); } else { @@ -293,12 +284,9 @@ private string[] GetRestoreArguments(IEnumerable cliRestoreArgs) "-nologo" }; - // --interactive need to output guide for auth. It cannot be - // completely "quiet" if (Verbosity is null) { - var defaultVerbosity = Interactive ? "minimal" : "quiet"; - args.Add($"-verbosity:{defaultVerbosity}"); + args.Add($"-verbosity:{GetDefaultVerbosity()}"); } args.AddRange(cliRestoreArgs); @@ -306,6 +294,13 @@ private string[] GetRestoreArguments(IEnumerable cliRestoreArgs) return args.ToArray(); } + private VerbosityOptions GetDefaultVerbosity() + { + // --interactive need to output guide for auth. It cannot be + // completely "quiet" + return Interactive ? VerbosityOptions.minimal : VerbosityOptions.quiet; + } + private ICommand GetTargetCommand(Func? projectFactory) { FacadeLogger? logger = DetermineBinlogger(RestoreArgs); From 4968d9a799d70e4ef5d5948fdf8e61e797365f69 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Wed, 5 Mar 2025 11:44:41 +0100 Subject: [PATCH 14/15] Fix TFM --- src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index ef2875b49bb2..413ef1f57909 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -150,7 +150,7 @@ private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCol Exe - net9.0 + net10.0 enable enable From 768eaab2ae5e04087dccaff546987b2b6ee88055 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 11 Mar 2025 11:07:01 +0100 Subject: [PATCH 15/15] Include only the entry-point file --- .../commands/VirtualProjectBuildingCommand.cs | 4 +++ test/dotnet-run.Tests/RunFileTests.cs | 34 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs index 413ef1f57909..c48d0e210556 100644 --- a/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -146,6 +146,7 @@ private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCol var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj"); var projectFileText = """ + @@ -153,6 +154,8 @@ private ProjectRootElement CreateProjectRootElement(ProjectCollection projectCol net10.0 enable enable + + false @@ -190,6 +193,7 @@ Override targets which don't work with project files that are not present on dis { projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); } + projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath); projectRoot.FullPath = projectFileFullPath; return projectRoot; } diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs index b8a70dcb92e1..79c4e85222cc 100644 --- a/test/dotnet-run.Tests/RunFileTests.cs +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -226,7 +226,7 @@ public void ProjectPath_Exists() .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Pass() - .And.HaveStdOut(""" + .And.HaveStdOutContaining(""" echo args:./App.csproj Hello from App """); @@ -252,9 +252,6 @@ public void NonCsFileExtension(string fileName) .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); } - /// - /// The build fails when there are multiple files with entry points. - /// [Fact] public void MultipleEntryPoints() { @@ -265,8 +262,14 @@ public void MultipleEntryPoints() new DotnetCommand(Log, "run", "Program.cs") .WithWorkingDirectory(testInstance.Path) .Execute() - .Should().Fail() - .And.HaveStdErrContaining(LocalizableStrings.RunCommandException); + .Should().Pass() + .And.HaveStdOut("Hello from Program"); + + new DotnetCommand(Log, "run", "Program2.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program2"); } /// @@ -317,7 +320,7 @@ public void ClassLibrary_EntryPointFileDoesNotExist() } /// - /// Other files in the folder are part of the compilation. + /// Other files in the folder are not part of the compilation. /// [Fact] public void MultipleFiles_RunEntryPoint() @@ -329,8 +332,8 @@ public void MultipleFiles_RunEntryPoint() new DotnetCommand(Log, "run", "Program.cs") .WithWorkingDirectory(testInstance.Path) .Execute() - .Should().Pass() - .And.HaveStdOut("Hello, String from Util"); + .Should().Fail() + .And.HaveStdOutContaining("error CS0103"); // The name 'Util' does not exist in the current context } /// @@ -631,14 +634,21 @@ public void BinaryLog_NotSpecified() } /// - /// Default projects include and compile also non-C# files, like resources. + /// Default projects do not include anything apart from the entry-point file. /// [Fact] public void EmbeddedResource() { var testInstance = _testAssetsManager.CreateTestDirectory(); File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - using var stream = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream("Program.Resources.resources")!; + using var stream = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream("Program.Resources.resources"); + + if (stream is null) + { + Console.WriteLine("Resource not found"); + return; + } + using var reader = new System.Resources.ResourceReader(stream); Console.WriteLine(reader.Cast().Single()); """); @@ -655,7 +665,7 @@ public void EmbeddedResource() .Execute() .Should().Pass() .And.HaveStdOut(""" - [MyString, TestValue] + Resource not found """); }