Skip to content

Allow reading file-based app from stdin #49348

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")'`.

Expand Down
4 changes: 4 additions & 0 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
<value>Cannot combine option '{0}' and '{1}'.</value>
<comment>{0} and {1} are option names like '--no-build'.</comment>
</data>
<data name="InvalidOptionForStdin" xml:space="preserve">
<value>Cannot specify option '{0}' when also using '-' to read the file from standard input.</value>
<comment>{0} is an option name like '--no-build'.</comment>
</data>
<data name="NoBinaryLogBecauseUpToDate" xml:space="preserve">
<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>
<comment>{Locked="--no-cache"}</comment>
Expand Down
6 changes: 4 additions & 2 deletions src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -104,7 +105,8 @@ public override RunApiOutput Execute()
interactive: false,
verbosity: VerbosityOptions.quiet,
restoreArgs: [],
args: [EntryPointFileFullPath],
args: [],
readCodeFromStdin: false,
environmentVariables: ReadOnlyDictionary<string, string>.Empty);

runCommand.TryGetLaunchProfileSettingsIfNeeded(out var launchSettings);
Expand Down
92 changes: 74 additions & 18 deletions src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,11 +23,6 @@ public class RunCommand
{
public bool NoBuild { get; }

/// <summary>
/// Value of the <c>--project</c> option.
/// </summary>
public string? ProjectFileOrDirectory { get; }

/// <summary>
/// Full path to a project file to run.
/// <see langword="null"/> if running without a project file
Expand All @@ -39,6 +35,13 @@ public class RunCommand
/// </summary>
public string? EntryPointFileFullPath { get; }

/// <summary>
/// Whether <c>dotnet run -</c> is being executed.
/// In that case, <see cref="EntryPointFileFullPath"/> points to a temporary file
/// containing all text read from the standard input.
/// </summary>
public bool ReadCodeFromStdin { get; }

public string[] Args { get; set; }
public bool NoRestore { get; }
public bool NoCache { get; }
Expand All @@ -63,7 +66,8 @@ public class RunCommand

public RunCommand(
bool noBuild,
string? projectFileOrDirectory,
string? projectFileFullPath,
string? entryPointFileFullPath,
string? launchProfile,
bool noLaunchProfile,
bool noLaunchProfileArguments,
Expand All @@ -73,12 +77,16 @@ public RunCommand(
VerbosityOptions? verbosity,
string[] restoreArgs,
string[] args,
bool readCodeFromStdin,
IReadOnlyDictionary<string, string> 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;
Expand Down Expand Up @@ -117,6 +125,7 @@ public int Execute()

if (EntryPointFileFullPath is not null)
{
Debug.Assert(!ReadCodeFromStdin);
projectFactory = CreateVirtualCommand().PrepareProjectInstance().CreateProjectInstance;
}
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Unix can't assume that only current user has access to this file path. Don't we need to enforce current user permissions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. It seems that GetTempFileName uses mkstemps which creates the file with 0600 permissions. I'm not sure if all that is guaranteed, but I'd hope it is, I see GetTempFileName used all over the place without any additional code to enforce the unix mode.

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),
Expand All @@ -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<string, string>.Empty
);

Expand Down
5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading