diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index f3b723d872f1..a4baf3c4e21b 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -65,7 +65,11 @@ For example, the remaining command-line arguments after the first argument (the (except for the arguments recognized by `dotnet run` unless they are after the `--` separator) and working directory is not changed (e.g., `cd /x/ && dotnet run /y/file.cs` runs the program in directory `/x/`). -`dotnet path.cs` is a shortcut for `dotnet run path.cs` provided that `path.cs` is a valid [target path](#target-path). +If a dash (`-`) is given instead of the target path (i.e., `dotnet run -`), the C# file to be executed is read from the standard input. +In this case, the current working directory is not used to search for other files (launch profiles, other sources in case of multi-file apps); +the compilation consists solely of the single file read from the standard input. + +`dotnet path.cs` is a shortcut for `dotnet run path.cs` provided that `path.cs` is a valid [target path](#target-path) (`dotnet -` is currently not supported). ### Other commands @@ -247,10 +251,6 @@ This section outlines potential future enhancements and alternatives considered. We could allow folders as the target path in the future (e.g., `dotnet run ./my-app/`). -An option like `dotnet run --cs-from-stdin` could read the C# file from standard input. -In this case, the current working directory would not be used to search for project or other C# files; -the compilation would consist solely of the single file read from standard input. - Similarly, it could be possible to specify the whole C# source text in a command-line argument like `dotnet run --cs-code 'Console.WriteLine("Hi")'`. diff --git a/src/Cli/dotnet/Commands/CliCommandStrings.resx b/src/Cli/dotnet/Commands/CliCommandStrings.resx index 0d75f1886f0d..b49657f83019 100644 --- a/src/Cli/dotnet/Commands/CliCommandStrings.resx +++ b/src/Cli/dotnet/Commands/CliCommandStrings.resx @@ -1535,6 +1535,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + Warning: Binary log option was specified but build will be skipped because output is up to date, specify '--no-cache' to force build. {Locked="--no-cache"} diff --git a/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs index dcf6d0bf3363..bea850fe2a45 100644 --- a/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs +++ b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs @@ -95,7 +95,8 @@ public override RunApiOutput Execute() var runCommand = new RunCommand( noBuild: false, - projectFileOrDirectory: null, + projectFileFullPath: null, + entryPointFileFullPath: EntryPointFileFullPath, launchProfile: null, noLaunchProfile: false, noLaunchProfileArguments: false, @@ -104,7 +105,8 @@ public override RunApiOutput Execute() interactive: false, verbosity: VerbosityOptions.quiet, restoreArgs: [], - args: [EntryPointFileFullPath], + args: [], + readCodeFromStdin: false, environmentVariables: ReadOnlyDictionary.Empty); runCommand.TryGetLaunchProfileSettingsIfNeeded(out var launchSettings); diff --git a/src/Cli/dotnet/Commands/Run/RunCommand.cs b/src/Cli/dotnet/Commands/Run/RunCommand.cs index e23b6fb48517..a98c3b52148f 100644 --- a/src/Cli/dotnet/Commands/Run/RunCommand.cs +++ b/src/Cli/dotnet/Commands/Run/RunCommand.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.CommandLine; +using System.CommandLine.Parsing; using System.Diagnostics; using Microsoft.Build.Evaluation; using Microsoft.Build.Exceptions; @@ -22,11 +23,6 @@ public class RunCommand { public bool NoBuild { get; } - /// - /// Value of the --project option. - /// - public string? ProjectFileOrDirectory { get; } - /// /// Full path to a project file to run. /// if running without a project file @@ -39,6 +35,13 @@ public class RunCommand /// public string? EntryPointFileFullPath { get; } + /// + /// Whether dotnet run - is being executed. + /// In that case, points to a temporary file + /// containing all text read from the standard input. + /// + public bool ReadCodeFromStdin { get; } + public string[] Args { get; set; } public bool NoRestore { get; } public bool NoCache { get; } @@ -63,7 +66,8 @@ public class RunCommand public RunCommand( bool noBuild, - string? projectFileOrDirectory, + string? projectFileFullPath, + string? entryPointFileFullPath, string? launchProfile, bool noLaunchProfile, bool noLaunchProfileArguments, @@ -73,12 +77,16 @@ public RunCommand( VerbosityOptions? verbosity, string[] restoreArgs, string[] args, + bool readCodeFromStdin, IReadOnlyDictionary environmentVariables) { + Debug.Assert(projectFileFullPath is null ^ entryPointFileFullPath is null); + Debug.Assert(!readCodeFromStdin || entryPointFileFullPath is not null); + NoBuild = noBuild; - ProjectFileOrDirectory = projectFileOrDirectory; - ProjectFileFullPath = DiscoverProjectFilePath(projectFileOrDirectory, ref args, out string? entryPointFileFullPath); + ProjectFileFullPath = projectFileFullPath; EntryPointFileFullPath = entryPointFileFullPath; + ReadCodeFromStdin = readCodeFromStdin; LaunchProfile = launchProfile; NoLaunchProfile = noLaunchProfile; NoLaunchProfileArguments = noLaunchProfileArguments; @@ -117,6 +125,7 @@ public int Execute() if (EntryPointFileFullPath is not null) { + Debug.Assert(!ReadCodeFromStdin); projectFactory = CreateVirtualCommand().PrepareProjectInstance().CreateProjectInstance; } } @@ -180,7 +189,7 @@ internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel return true; } - var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!); + var launchSettingsPath = ReadCodeFromStdin ? null : TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!); if (!File.Exists(launchSettingsPath)) { if (!string.IsNullOrEmpty(LaunchProfile)) @@ -437,7 +446,7 @@ private static void ThrowUnableToRunError(ProjectInstance project) project.GetPropertyValue("OutputType"))); } - private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, ref string[] args, out string? entryPointFilePath) + private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, bool readCodeFromStdin, ref string[] args, out string? entryPointFilePath) { bool emptyProjectOption = false; if (string.IsNullOrWhiteSpace(projectFileOrDirectoryPath)) @@ -453,7 +462,7 @@ private static void ThrowUnableToRunError(ProjectInstance project) // 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) + ? TryFindEntryPointFilePath(readCodeFromStdin, ref args) : null; if (entryPointFilePath is null && projectFilePath is null) @@ -480,16 +489,27 @@ private static void ThrowUnableToRunError(ProjectInstance project) return projectFiles[0]; } - static string? TryFindEntryPointFilePath(ref string[] args) + static string? TryFindEntryPointFilePath(bool readCodeFromStdin, ref string[] args) { - if (args is not [{ } arg, ..] || - !VirtualProjectBuildingCommand.IsValidEntryPointPath(arg)) + if (args is not [{ } arg, ..]) { return null; } + if (!readCodeFromStdin) + { + if (VirtualProjectBuildingCommand.IsValidEntryPointPath(arg)) + { + arg = Path.GetFullPath(arg); + } + else + { + return null; + } + } + args = args[1..]; - return Path.GetFullPath(arg); + return arg; } } @@ -521,9 +541,44 @@ public static RunCommand FromParseResult(ParseResult parseResult) restoreArgs.AddRange(binLogArgs); } + // Only consider `-` to mean "read code from stdin" if it is before double dash `--` + // (otherwise it should be fowarded to the target application as its command-line argument). + bool readCodeFromStdin = nonBinLogArgs is ["-", ..] && + parseResult.Tokens.TakeWhile(static t => t.Type != TokenType.DoubleDash) + .Any(static t => t is { Type: TokenType.Argument, Value: "-" }); + + string? projectOption = parseResult.GetValue(RunCommandParser.ProjectOption); + + string[] args = [.. nonBinLogArgs]; + string? projectFilePath = DiscoverProjectFilePath(projectOption, readCodeFromStdin, ref args, out string? entryPointFilePath); + + bool noBuild = parseResult.HasOption(RunCommandParser.NoBuildOption); + + if (readCodeFromStdin && entryPointFilePath != null) + { + Debug.Assert(projectFilePath is null && entryPointFilePath is "-"); + + if (noBuild) + { + throw new GracefulException(CliCommandStrings.InvalidOptionForStdin, RunCommandParser.NoBuildOption.Name); + } + + // If '-' is specified as the input file, read all text from stdin into a temporary file and use that as the entry point. + entryPointFilePath = Path.GetTempFileName(); + using (var stdinStream = Console.OpenStandardInput()) + using (var fileStream = File.OpenWrite(entryPointFilePath)) + { + stdinStream.CopyTo(fileStream); + } + + Debug.Assert(nonBinLogArgs[0] == "-"); + nonBinLogArgs[0] = entryPointFilePath; + } + var command = new RunCommand( - noBuild: parseResult.HasOption(RunCommandParser.NoBuildOption), - projectFileOrDirectory: parseResult.GetValue(RunCommandParser.ProjectOption), + noBuild: noBuild, + projectFileFullPath: projectFilePath, + entryPointFileFullPath: entryPointFilePath, launchProfile: parseResult.GetValue(RunCommandParser.LaunchProfileOption) ?? string.Empty, noLaunchProfile: parseResult.HasOption(RunCommandParser.NoLaunchProfileOption), noLaunchProfileArguments: parseResult.HasOption(RunCommandParser.NoLaunchProfileArgumentsOption), @@ -532,7 +587,8 @@ public static RunCommand FromParseResult(ParseResult parseResult) interactive: parseResult.GetValue(RunCommandParser.InteractiveOption), verbosity: parseResult.HasOption(CommonOptions.VerbosityOption) ? parseResult.GetValue(CommonOptions.VerbosityOption) : null, restoreArgs: [.. restoreArgs], - args: [.. nonBinLogArgs], + args: args, + readCodeFromStdin: readCodeFromStdin, environmentVariables: parseResult.GetValue(CommonOptions.EnvOption) ?? ImmutableDictionary.Empty ); diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf index ba28b98acfff..b468dc23331d 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf index 6d12b3b73c6d..d719ba37e98e 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf index 7cea1d5bbe96..d961ac571997 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf index 735acf92f982..1c75a5a114c7 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf index 532c09dc5165..256db7517884 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf index 2d73b067eef5..ffe3037fc535 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf index efd09bb2926b..0d29f919d80d 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf index 580751117300..8fea0d19b119 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf index 92d9adb2d5fb..078d04c02bb0 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf index 788593aa84f3..9feb1fa5c88b 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf index a1a3e504e551..612d541373a7 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf index 7287768e5e23..3606de77cb4a 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf index 5ef7765135d7..4e75a93f0900 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf @@ -1451,6 +1451,11 @@ Make the profile names distinct. Cannot combine option '{0}' and '{1}'. {0} and {1} are option names like '--no-build'. + + Cannot specify option '{0}' when also using '-' to read the file from standard input. + Cannot specify option '{0}' when also using '-' to read the file from standard input. + {0} is an option name like '--no-build'. + The '#:project' directive at '{0}' is invalid: {1} The '#:project' directive at '{0}' is invalid: {1} diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 02a5bccdd43c..b31da0a26864 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -220,6 +220,50 @@ public void FilePath_AsProjectArgument() .And.HaveStdErrContaining(CliCommandStrings.RunCommandException); } + /// + /// dotnet run - reads the C# code from stdin. + /// + [Fact] + public void ReadFromStdin() + { + new DotnetCommand(Log, "run", "-") + .WithStandardInput(""" + Console.WriteLine("Hello from stdin"); + Console.WriteLine("Read: " + (Console.ReadLine() ?? "null")); + """) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + Hello from stdin + Read: null + """); + } + + [Fact] + public void ReadFromStdin_NoBuild() + { + new DotnetCommand(Log, "run", "-", "--no-build") + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.InvalidOptionForStdin, RunCommandParser.NoBuildOption.Name)); + } + + /// + /// dotnet run -- - should NOT read the C# file from stdin, + /// the hyphen should be considred an app argument instead since it's after --. + /// + [Fact] + public void ReadFromStdin_AfterDoubleDash() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + new DotnetCommand(Log, "run", "--", "-") + .WithWorkingDirectory(testInstance.Path) + .WithStandardInput("""Console.WriteLine("stdin code");""") + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.RunCommandExceptionNoProjects, testInstance.Path, RunCommandParser.ProjectOption.Name)); + } + /// /// dotnet run folder without a project file is not supported. ///