Skip to content

Prefer CLI commands to loose files #49485

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

Conversation

baronfel
Copy link
Member

@baronfel baronfel commented Jun 19, 2025

When testing #49461 (comment) I discovered that #48387 introduced a subtle failure mode - if you have a file that we think is a C# runnable file in the current directory that matches the name of a CLI command, we'll prefer your file over the CLI command.

This is backwards to me - we should be preferring our commands. This PR does that!

Some proof is in order: Here I have a directory with a file called dnx in it. I explicitly run it, and we see a build failure because I'm missing NuGet feeds. Point is, we're running the single-file app.

Then, I call dotnet dnx. Before, this would also try to run the app. Now it correctly runs the actual dnx command.

(dogfood) PS E:\Code\Scratch\foo> dotnet run dnx
    E:\Code\Scratch\foo\dnx.csproj : error NU1102:
      Unable to find package Microsoft.NET.ILLink.Tasks with version (>= 10.0.0-preview.6.25316.103)
        - Found 54 version(s) in nuget [ Nearest version: 10.0.0-preview.5.25277.114 ]

The build failed. Fix the build errors and run again.
(dogfood) PS E:\Code\Scratch\foo> dotnet dnx
Required argument missing for command: 'dnx'.

Description:
  Executes a tool from source without permanently installing it.

Usage:
  dotnet dnx <packageId> [<commandArguments>...] [options]

Arguments:
  <PACKAGE_ID>        Package reference in the form of a package identifier like 'Newtonsoft.Json' or package identifier and version separated by '@' like 'Newtonsoft.Json@13.0.3'.
  <commandArguments>  Arguments forwarded to the tool

Options:
  --version <VERSION>       The version of the tool package to install.
  -y, --yes                 Accept all confirmation prompts using "yes."
  --interactive             Allows the command to stop and wait for user input or action (for example to complete authentication). [default: True]
  --allow-roll-forward      Allow a .NET tool to roll forward to newer versions of the .NET runtime if the runtime it targets isn't installed.
  --prerelease              Include pre-release packages.
  --configfile <FILE>       The NuGet configuration file to use.
  --source <SOURCE>         Replace all NuGet package sources to use during installation with these.
  --add-source <ADDSOURCE>  Add an additional NuGet package source to use during installation.
  --disable-parallel        Prevent restoring multiple projects in parallel.
  --ignore-failed-sources   Treat package source failures as warnings.
  --no-http-cache           Do not cache packages and http requests.
  -v, --verbosity <LEVEL>   Set the MSBuild verbosity level. Allowed values are q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic].
  -?, -h, --help            Show command line help.

@baronfel baronfel requested review from a team and Copilot June 19, 2025 03:31
@baronfel baronfel added the Area-run-file Items related to the "dotnet run <file>" effort label Jun 19, 2025
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR updates the argument parsing logic in dotnet's CLI to favor built-in commands over user C# files that share the same name as a CLI command.

  • Simplifies the initial parsing of arguments by always calling Parser.Instance.Parse(args).
  • Adds a conditional re-parsing that prepends the "run" command when an unmatched file path is detected.
Comments suppressed due to low confidence (1)

src/Cli/dotnet/Program.cs:139

  • [nitpick] Add a comment clarifying why tokens with a type other than Argument are included even if they match the unmatched command; this will aid future maintainers in understanding the token filtering logic.
                foreach (var token in parseResult.Tokens)

parseResult = Parser.Instance.Parse(args);
// If we get didn't match any built-in commands, and a C# file path is the first argument,
// parse as `dotnet run file.cs ..rest_of_args` instead.
if (parseResult.CommandResult.Command is RootCommand
Copy link
Member Author

Choose a reason for hiding this comment

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

If we don't match any 'actual' commands, the CommandResult will be dotnet itself because of how we've modeled it in Parser.cs. We can inspect the intended state of the ParseResult and figure out what the user intended from there.

// If we get didn't match any built-in commands, and a C# file path is the first argument,
// parse as `dotnet run file.cs ..rest_of_args` instead.
if (parseResult.CommandResult.Command is RootCommand
&& parseResult.GetValue(Parser.DotnetSubCommand) is { } unmatchedCommandOrFile
Copy link
Member Author

Choose a reason for hiding this comment

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

This DotnetSubCommand argument (misnomer alert!) is also used later in the Program.cs when we try to run globally-installed tools with the naming convention dotnet-<arg>. It's intended for analysis like this!

@baronfel
Copy link
Member Author

/backport to release/10.0.1xx-preview6

Copy link
Contributor

Started backporting to release/10.0.1xx-preview6: https://github.com/dotnet/sdk/actions/runs/15749026895

&& VirtualProjectBuildingCommand.IsValidEntryPointPath(unmatchedCommandOrFile))
{
List<string> otherTokens = new(parseResult.Tokens.Count - 1);
foreach (var token in parseResult.Tokens)
Copy link
Member

@jjonescz jjonescz Jun 19, 2025

Choose a reason for hiding this comment

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

Why do we need a foreach? Are there situations when the unmatchedCommandOrFile won't be the first token? And if there are such situations, does it matter that we reorder the tokens below (we put unmatchedCommandOrFile before all other tokens)?

Copy link
Member Author

Choose a reason for hiding this comment

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

surprisingly yes! Here are some examples (assume a file called 'dnx' in the CWD where these commands are executed):

  • dotnet dnx
    • Tokens has 1 entry (argument: dnx), could just skip the first one
  • dotnet dnx --help
    • Tokens has two entries (argument: dnx, option:--help) , could just skip the first one. Note that in this case --help is recognized as an Option token because the dotnet RootCommand exposes a --help option.
  • dotnet dnx --arbitrary
    • Tokens has two entries (argument: dnx, argument:--arbitrary), could just skip the first one. Note that in this case --arbitrary is classified as an Argument token because there's no Option exposed on the dotnet RootCommand that matches that token

So far, all of this has been able to be handled by a .Skip(1). The only real problem comes in the following kind of case:

  • dotnet --help dnx
    • Tokens has two entries (option:--help, argument:dnx), and we can't just skip the first one. In this case, because an token was recognized as an Option (because it matched an Option definition on the dotnet RootCommand), this syntax is valid. This is because in S.CL, options configured on a command are valid for placement anywhere after the command they are attached to is recognized - they become 'in scope' in a way. This means that to handle this case we need to do the foreach on Tokens.

As to your question about ordering - we are maintaining the original ordering as much as possible, and I think the only real impact here is that any dotnet-based Option tokens will appear immediately after dotnet run <arg>, e.g.:

dotnet --help foo --arbitrary => dotnet run foo --help --arbitrary

This seems like a reasonable remapping to me.

Copy link
Member

Choose a reason for hiding this comment

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

Regarding reordering, I noticed the following in the current SDK:

  • dotnet --help run shows the help text for run subcommand.
  • dotnet --help myapp.dll fails with an error message, it does not launch myapp.dll.
Could not execute because the specified command or file was not found.
Possible reasons for this include:
  * You misspelled a built-in dotnet command.
  * You intended to execute a .NET program, but dotnet-myapp.dll does not exist.
  * You intended to run a global tool, but a dotnet-prefixed executable with this name could not be found on the PATH.

Having seen all that I think the solution in this PR is most consistent with the idea that this is just a "conditional alias" for dotnet run.

Co-authored-by: Jan Jones <jan.jones.cz@gmail.com>
@DamianEdwards
Copy link
Member

@baronfel does this still prefer loose files if they're specified with an qualified path, e.g. dotnet ./foobar? I think it should.

@baronfel
Copy link
Member Author

@DamianEdwards if we ever made a CLI command that started with a ./ you should feel free to reach through the internet and punch me :D

With this PR, we'd see if a CLI command matching ./foobar existed (which it won't) and then we'd check the VirtualProjectFile logic to see if that was a match. That seems fine/reasonable to me?

@RikkiGibson
Copy link
Member

Can we add 1-2 tests which would fail with the original implementation, but pass with this implementation?

@baronfel
Copy link
Member Author

@RikkiGibson I'm going to merge this so we have the fix, but @jjonescz volunteered to add some later.

@baronfel baronfel merged commit 18e9961 into dotnet:main Jun 19, 2025
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-run-file Items related to the "dotnet run <file>" effort
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants