diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs index 6ecc1f539a3c..496097bb50ce 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs @@ -36,13 +36,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" ]; @@ -200,6 +194,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..c48d0e210556 --- /dev/null +++ b/src/Cli/dotnet/commands/VirtualProjectBuildingCommand.cs @@ -0,0 +1,200 @@ +// 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; +using Microsoft.DotNet.Tools.Run; + +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, ILogger consoleLogger) + { + var binaryLogger = GetBinaryLogger(binaryLoggerArgs); + Dictionary savedEnvironmentVariables = new(); + try + { + // Set environment variables. + foreach (var (key, value) in MSBuildForwardingAppWithoutLogging.GetMSBuildRequiredEnvironmentVariables()) + { + savedEnvironmentVariables[key] = Environment.GetEnvironmentVariable(key); + Environment.SetEnvironmentVariable(key, value); + } + + // Set up 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 = binaryLoggers.Length != 0, + }; + 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 + // and https://github.com/dotnet/msbuild/issues/11519. + 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) + { + // 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)) + { + 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) + { + 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 + net10.0 + enable + enable + + false + + + + + + + + + + + + + + + <_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" /> + + + + + + + + """; + ProjectRootElement projectRoot; + using (var xmlReader = XmlReader.Create(new StringReader(projectFileText))) + { + projectRoot = ProjectRootElement.Create(xmlReader, projectCollection); + } + projectRoot.AddItem(itemType: "Compile", include: EntryPointFileFullPath); + 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..79325816a555 100644 --- a/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx +++ b/src/Cli/dotnet/commands/dotnet-run/LocalizableStrings.resx @@ -244,4 +244,7 @@ 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}' + diff --git a/src/Cli/dotnet/commands/dotnet-run/Program.cs b/src/Cli/dotnet/commands/dotnet-run/Program.cs index 0b7d76ec648f..e6dbf2f0225d 100644 --- a/src/Cli/dotnet/commands/dotnet-run/Program.cs +++ b/src/Cli/dotnet/commands/dotnet-run/Program.cs @@ -19,6 +19,14 @@ public static RunCommand FromArgs(string[] args) return FromParseResult(parseResult); } + internal static bool IsBinLogArgument(string arg) + { + 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); + } + public static RunCommand FromParseResult(ParseResult parseResult) { if (parseResult.UsingRunCommandShorthandProjectOption()) @@ -37,10 +45,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); } diff --git a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs index d596c6a60714..0a237a80caa7 100644 --- a/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs @@ -1,12 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; +#nullable enable + +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.Extensions; using Microsoft.DotNet.Cli.Utils; @@ -21,8 +26,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; } @@ -59,7 +80,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; @@ -78,6 +100,7 @@ public int Execute() return 1; } + Func? projectFactory = null; if (ShouldBuild) { if (string.Equals("true", launchSettings?.DotNetRunMessages, StringComparison.OrdinalIgnoreCase)) @@ -85,12 +108,19 @@ public int Execute() Reporter.Output.WriteLine(LocalizableStrings.RunCommandBuilding); } - EnsureProjectIsBuilt(); + EnsureProjectIsBuilt(out projectFactory); + } + else if (EntryPointFileFullPath is not null) + { + projectFactory = new VirtualProjectBuildingCommand + { + EntryPointFileFullPath = EntryPointFileFullPath, + }.CreateProjectInstance; } try { - ICommand targetCommand = GetTargetCommand(); + ICommand targetCommand = GetTargetCommand(projectFactory); ApplyLaunchSettingsProfileToCommand(targetCommand, launchSettings); // Env variables specified on command line override those specified in launch profile: @@ -147,7 +177,7 @@ private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? return true; } - var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath); + var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!); if (!File.Exists(launchSettingsPath)) { if (!string.IsNullOrEmpty(LaunchProfile)) @@ -186,9 +216,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; @@ -199,7 +229,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"; } @@ -213,14 +243,34 @@ 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, + consoleLogger: MakeTerminalLogger(Verbosity ?? GetDefaultVerbosity())); + } + else + { + Debug.Assert(ProjectFileFullPath is not null); + + projectFactory = null; + buildResult = new RestoringCommand( RestoreArgs.Prepend(ProjectFileFullPath), NoRestore, advertiseWorkloadUpdates: false ).Execute(); + } if (buildResult != 0) { @@ -236,12 +286,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); @@ -249,10 +296,17 @@ private string[] GetRestoreArguments(IEnumerable cliRestoreArgs) return args.ToArray(); } - private ICommand GetTargetCommand() + 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); - var project = EvaluateProject(ProjectFileFullPath, RestoreArgs, logger); + var project = EvaluateProject(ProjectFileFullPath, projectFactory, RestoreArgs, logger); ValidatePreconditions(project); InvokeRunArgumentsTarget(project, RestoreArgs, Verbosity, logger); logger?.ReallyShutdown(); @@ -260,8 +314,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 @@ -270,16 +326,17 @@ static ProjectInstance EvaluateProject(string projectFilePath, string[] restoreA { Constants.MSBuildExtensionsPath, AppContext.BaseDirectory } }; - var userPassedProperties = DeriveUserPassedProperties(restoreArgs); - if (userPassedProperties is not null) + AddUserPassedProperties(globalProperties, restoreArgs); + + var collection = new ProjectCollection(globalProperties: globalProperties, loggers: binaryLogger is null ? null : [binaryLogger], toolsetDefinitionLocations: ToolsetDefinitionLocations.Default); + + if (projectFilePath is not null) { - foreach (var (key, values) in userPassedProperties) - { - globalProperties[key] = string.Join(";", values); - } + return collection.LoadProject(projectFilePath).CreateProjectInstance(); } - var collection = new ProjectCollection(globalProperties: globalProperties, loggers: binaryLogger is null ? null : [binaryLogger], toolsetDefinitionLocations: ToolsetDefinitionLocations.Default); - return collection.LoadProject(projectFilePath).CreateProjectInstance(); + + Debug.Assert(projectFactory is not null); + return projectFactory(collection); } static void ValidatePreconditions(ProjectInstance project) @@ -290,35 +347,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"); @@ -375,11 +403,16 @@ 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))) + for (int i = restoreArgs.Length - 1; i >= 0; i--) { + string blArg = restoreArgs[i]; + if (!IsBinLogArgument(blArg)) + { + continue; + } + if (blArg.Contains(':')) { // split and forward args @@ -398,6 +431,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 @@ -419,6 +455,37 @@ static FacadeLogger ConfigureDispatcher(List binaryLoggers) } } + /// + /// Should have . + /// + 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); + 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. @@ -547,34 +614,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) { + bool emptyProjectOption = false; if (string.IsNullOrWhiteSpace(projectFileOrDirectoryPath)) { + emptyProjectOption = true; 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]; } - else if (projectFiles.Length > 1) + + static string? TryFindEntryPointFilePath(ref string[] args) { - throw new GracefulException(LocalizableStrings.RunCommandExceptionMultipleProjects, directory); + if (args is not [{ } 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); } - return projectFiles[0]; + static bool HasTopLevelStatements(string entryPointFilePath) + { + var tree = ParseCSharp(entryPointFilePath); + return tree.GetRoot().ChildNodes().OfType().Any(); + } + + 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..abfc7d427c45 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. 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..0536b262adc6 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. 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..6493f1225514 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. 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..ba6804b350aa 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. 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..824c4eec596d 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. 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..2b85677d654a 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 つだけです。 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..f6763e33f8a2 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 옵션을 사용하여 한 번에 하나의 프로젝트만 지정할 수 있습니다. 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..b2caf7058159 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. 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..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 @@ -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. 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..54cdd26340fb 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 можно указать только один проект за раз. 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..6ee59dd32733 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. 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..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 @@ -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 选项时一次只能指定一个项目。 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..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 @@ -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 選項時,一次只能指定一個專案。 diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index 7cdb351f6768..8f5cf52e0d3d 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -115,6 +115,7 @@ + diff --git a/test/dotnet-run.Tests/RunFileTests.cs b/test/dotnet-run.Tests/RunFileTests.cs new file mode 100644 index 000000000000..79c4e85222cc --- /dev/null +++ b/test/dotnet-run.Tests/RunFileTests.cs @@ -0,0 +1,747 @@ +// 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 + {ToolsetInfo.CurrentTargetFramework} + enable + + + """; + + 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."; + + 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, 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(); + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + + File.WriteAllText(programPath, s_program); + + path ??= programPath; + + var result = new DotnetCommand(Log, "run", path) + .WithWorkingDirectory(testInstance.Path) + .Execute(); + + if (!differentCasing || HasCaseInsensitiveFileSystem) + { + result.Should().Pass() + .And.HaveStdOut("Hello from Program"); + } + else + { + result.Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + } + + /// + /// 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); + + var result = new DotnetCommand(Log, "run", "program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute(); + + if (HasCaseInsensitiveFileSystem) + { + result.Should().Pass() + .And.HaveStdOut("Hello from program"); + } + else + { + result.Should().Fail() + .And.HaveStdErrContaining(s_runCommandExceptionNoProjects); + } + } + + /// + /// 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.HaveStdOutContaining(""" + 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); + } + + [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().Pass() + .And.HaveStdOut("Hello from Program"); + + new DotnetCommand(Log, "run", "Program2.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from Program2"); + } + + /// + /// 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 not 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().Fail() + .And.HaveStdOutContaining("error CS0103"); // The name 'Util' does not exist in the current context + } + + /// + /// 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 + """); + } + + /// + /// 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"]); + } + + [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(["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. + /// + [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(); + } + + /// + /// 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"); + + if (stream is null) + { + Console.WriteLine("Resource not found"); + return; + } + + 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(""" + Resource not found + """); + } + + [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("An error occurred trying to start process"); + + // 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"); + } + + [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' + """); + } +} diff --git a/test/dotnet.Tests/dotnet.Tests.csproj b/test/dotnet.Tests/dotnet.Tests.csproj index e22cdf7dfa23..7731ac8d9ad7 100644 --- a/test/dotnet.Tests/dotnet.Tests.csproj +++ b/test/dotnet.Tests/dotnet.Tests.csproj @@ -126,6 +126,7 @@ +