Skip to content

Commit e6168d6

Browse files
authored
Allow reading file-based app from stdin (#49348)
1 parent 2b5cee4 commit e6168d6

18 files changed

+196
-25
lines changed

documentation/general/dotnet-run-file.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ For example, the remaining command-line arguments after the first argument (the
6565
(except for the arguments recognized by `dotnet run` unless they are after the `--` separator)
6666
and working directory is not changed (e.g., `cd /x/ && dotnet run /y/file.cs` runs the program in directory `/x/`).
6767

68-
`dotnet path.cs` is a shortcut for `dotnet run path.cs` provided that `path.cs` is a valid [target path](#target-path).
68+
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.
69+
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);
70+
the compilation consists solely of the single file read from the standard input.
71+
72+
`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).
6973

7074
### Other commands
7175

@@ -247,10 +251,6 @@ This section outlines potential future enhancements and alternatives considered.
247251

248252
We could allow folders as the target path in the future (e.g., `dotnet run ./my-app/`).
249253

250-
An option like `dotnet run --cs-from-stdin` could read the C# file from standard input.
251-
In this case, the current working directory would not be used to search for project or other C# files;
252-
the compilation would consist solely of the single file read from standard input.
253-
254254
Similarly, it could be possible to specify the whole C# source text in a command-line argument
255255
like `dotnet run --cs-code 'Console.WriteLine("Hi")'`.
256256

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
15351535
<value>Cannot combine option '{0}' and '{1}'.</value>
15361536
<comment>{0} and {1} are option names like '--no-build'.</comment>
15371537
</data>
1538+
<data name="InvalidOptionForStdin" xml:space="preserve">
1539+
<value>Cannot specify option '{0}' when also using '-' to read the file from standard input.</value>
1540+
<comment>{0} is an option name like '--no-build'.</comment>
1541+
</data>
15381542
<data name="NoBinaryLogBecauseUpToDate" xml:space="preserve">
15391543
<value>Warning: Binary log option was specified but build will be skipped because output is up to date, specify '--no-cache' to force build.</value>
15401544
<comment>{Locked="--no-cache"}</comment>

src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ public override RunApiOutput Execute()
9595

9696
var runCommand = new RunCommand(
9797
noBuild: false,
98-
projectFileOrDirectory: null,
98+
projectFileFullPath: null,
99+
entryPointFileFullPath: EntryPointFileFullPath,
99100
launchProfile: null,
100101
noLaunchProfile: false,
101102
noLaunchProfileArguments: false,
@@ -104,7 +105,8 @@ public override RunApiOutput Execute()
104105
interactive: false,
105106
verbosity: VerbosityOptions.quiet,
106107
restoreArgs: [],
107-
args: [EntryPointFileFullPath],
108+
args: [],
109+
readCodeFromStdin: false,
108110
environmentVariables: ReadOnlyDictionary<string, string>.Empty);
109111

110112
runCommand.TryGetLaunchProfileSettingsIfNeeded(out var launchSettings);

src/Cli/dotnet/Commands/Run/RunCommand.cs

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Collections.Immutable;
55
using System.CommandLine;
6+
using System.CommandLine.Parsing;
67
using System.Diagnostics;
78
using Microsoft.Build.Evaluation;
89
using Microsoft.Build.Exceptions;
@@ -22,11 +23,6 @@ public class RunCommand
2223
{
2324
public bool NoBuild { get; }
2425

25-
/// <summary>
26-
/// Value of the <c>--project</c> option.
27-
/// </summary>
28-
public string? ProjectFileOrDirectory { get; }
29-
3026
/// <summary>
3127
/// Full path to a project file to run.
3228
/// <see langword="null"/> if running without a project file
@@ -39,6 +35,13 @@ public class RunCommand
3935
/// </summary>
4036
public string? EntryPointFileFullPath { get; }
4137

38+
/// <summary>
39+
/// Whether <c>dotnet run -</c> is being executed.
40+
/// In that case, <see cref="EntryPointFileFullPath"/> points to a temporary file
41+
/// containing all text read from the standard input.
42+
/// </summary>
43+
public bool ReadCodeFromStdin { get; }
44+
4245
public string[] Args { get; set; }
4346
public bool NoRestore { get; }
4447
public bool NoCache { get; }
@@ -63,7 +66,8 @@ public class RunCommand
6366

6467
public RunCommand(
6568
bool noBuild,
66-
string? projectFileOrDirectory,
69+
string? projectFileFullPath,
70+
string? entryPointFileFullPath,
6771
string? launchProfile,
6872
bool noLaunchProfile,
6973
bool noLaunchProfileArguments,
@@ -73,12 +77,16 @@ public RunCommand(
7377
VerbosityOptions? verbosity,
7478
string[] restoreArgs,
7579
string[] args,
80+
bool readCodeFromStdin,
7681
IReadOnlyDictionary<string, string> environmentVariables)
7782
{
83+
Debug.Assert(projectFileFullPath is null ^ entryPointFileFullPath is null);
84+
Debug.Assert(!readCodeFromStdin || entryPointFileFullPath is not null);
85+
7886
NoBuild = noBuild;
79-
ProjectFileOrDirectory = projectFileOrDirectory;
80-
ProjectFileFullPath = DiscoverProjectFilePath(projectFileOrDirectory, ref args, out string? entryPointFileFullPath);
87+
ProjectFileFullPath = projectFileFullPath;
8188
EntryPointFileFullPath = entryPointFileFullPath;
89+
ReadCodeFromStdin = readCodeFromStdin;
8290
LaunchProfile = launchProfile;
8391
NoLaunchProfile = noLaunchProfile;
8492
NoLaunchProfileArguments = noLaunchProfileArguments;
@@ -117,6 +125,7 @@ public int Execute()
117125

118126
if (EntryPointFileFullPath is not null)
119127
{
128+
Debug.Assert(!ReadCodeFromStdin);
120129
projectFactory = CreateVirtualCommand().PrepareProjectInstance().CreateProjectInstance;
121130
}
122131
}
@@ -180,7 +189,7 @@ internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel
180189
return true;
181190
}
182191

183-
var launchSettingsPath = TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!);
192+
var launchSettingsPath = ReadCodeFromStdin ? null : TryFindLaunchSettings(ProjectFileFullPath ?? EntryPointFileFullPath!);
184193
if (!File.Exists(launchSettingsPath))
185194
{
186195
if (!string.IsNullOrEmpty(LaunchProfile))
@@ -437,7 +446,7 @@ private static void ThrowUnableToRunError(ProjectInstance project)
437446
project.GetPropertyValue("OutputType")));
438447
}
439448

440-
private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, ref string[] args, out string? entryPointFilePath)
449+
private static string? DiscoverProjectFilePath(string? projectFileOrDirectoryPath, bool readCodeFromStdin, ref string[] args, out string? entryPointFilePath)
441450
{
442451
bool emptyProjectOption = false;
443452
if (string.IsNullOrWhiteSpace(projectFileOrDirectoryPath))
@@ -453,7 +462,7 @@ private static void ThrowUnableToRunError(ProjectInstance project)
453462
// If no project exists in the directory and no --project was given,
454463
// try to resolve an entry-point file instead.
455464
entryPointFilePath = projectFilePath is null && emptyProjectOption
456-
? TryFindEntryPointFilePath(ref args)
465+
? TryFindEntryPointFilePath(readCodeFromStdin, ref args)
457466
: null;
458467

459468
if (entryPointFilePath is null && projectFilePath is null)
@@ -480,16 +489,27 @@ private static void ThrowUnableToRunError(ProjectInstance project)
480489
return projectFiles[0];
481490
}
482491

483-
static string? TryFindEntryPointFilePath(ref string[] args)
492+
static string? TryFindEntryPointFilePath(bool readCodeFromStdin, ref string[] args)
484493
{
485-
if (args is not [{ } arg, ..] ||
486-
!VirtualProjectBuildingCommand.IsValidEntryPointPath(arg))
494+
if (args is not [{ } arg, ..])
487495
{
488496
return null;
489497
}
490498

499+
if (!readCodeFromStdin)
500+
{
501+
if (VirtualProjectBuildingCommand.IsValidEntryPointPath(arg))
502+
{
503+
arg = Path.GetFullPath(arg);
504+
}
505+
else
506+
{
507+
return null;
508+
}
509+
}
510+
491511
args = args[1..];
492-
return Path.GetFullPath(arg);
512+
return arg;
493513
}
494514
}
495515

@@ -521,9 +541,44 @@ public static RunCommand FromParseResult(ParseResult parseResult)
521541
restoreArgs.AddRange(binLogArgs);
522542
}
523543

544+
// Only consider `-` to mean "read code from stdin" if it is before double dash `--`
545+
// (otherwise it should be fowarded to the target application as its command-line argument).
546+
bool readCodeFromStdin = nonBinLogArgs is ["-", ..] &&
547+
parseResult.Tokens.TakeWhile(static t => t.Type != TokenType.DoubleDash)
548+
.Any(static t => t is { Type: TokenType.Argument, Value: "-" });
549+
550+
string? projectOption = parseResult.GetValue(RunCommandParser.ProjectOption);
551+
552+
string[] args = [.. nonBinLogArgs];
553+
string? projectFilePath = DiscoverProjectFilePath(projectOption, readCodeFromStdin, ref args, out string? entryPointFilePath);
554+
555+
bool noBuild = parseResult.HasOption(RunCommandParser.NoBuildOption);
556+
557+
if (readCodeFromStdin && entryPointFilePath != null)
558+
{
559+
Debug.Assert(projectFilePath is null && entryPointFilePath is "-");
560+
561+
if (noBuild)
562+
{
563+
throw new GracefulException(CliCommandStrings.InvalidOptionForStdin, RunCommandParser.NoBuildOption.Name);
564+
}
565+
566+
// If '-' is specified as the input file, read all text from stdin into a temporary file and use that as the entry point.
567+
entryPointFilePath = Path.GetTempFileName();
568+
using (var stdinStream = Console.OpenStandardInput())
569+
using (var fileStream = File.OpenWrite(entryPointFilePath))
570+
{
571+
stdinStream.CopyTo(fileStream);
572+
}
573+
574+
Debug.Assert(nonBinLogArgs[0] == "-");
575+
nonBinLogArgs[0] = entryPointFilePath;
576+
}
577+
524578
var command = new RunCommand(
525-
noBuild: parseResult.HasOption(RunCommandParser.NoBuildOption),
526-
projectFileOrDirectory: parseResult.GetValue(RunCommandParser.ProjectOption),
579+
noBuild: noBuild,
580+
projectFileFullPath: projectFilePath,
581+
entryPointFileFullPath: entryPointFilePath,
527582
launchProfile: parseResult.GetValue(RunCommandParser.LaunchProfileOption) ?? string.Empty,
528583
noLaunchProfile: parseResult.HasOption(RunCommandParser.NoLaunchProfileOption),
529584
noLaunchProfileArguments: parseResult.HasOption(RunCommandParser.NoLaunchProfileArgumentsOption),
@@ -532,7 +587,8 @@ public static RunCommand FromParseResult(ParseResult parseResult)
532587
interactive: parseResult.GetValue(RunCommandParser.InteractiveOption),
533588
verbosity: parseResult.HasOption(CommonOptions.VerbosityOption) ? parseResult.GetValue(CommonOptions.VerbosityOption) : null,
534589
restoreArgs: [.. restoreArgs],
535-
args: [.. nonBinLogArgs],
590+
args: args,
591+
readCodeFromStdin: readCodeFromStdin,
536592
environmentVariables: parseResult.GetValue(CommonOptions.EnvOption) ?? ImmutableDictionary<string, string>.Empty
537593
);
538594

src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)