Enabling Command Line Completions with dotnet-suggest

I recently removed the hand-written command line parser from C# REPL and replaced it with the more standard System.CommandLine NuGet package. As part of this, it gained dotnet-suggest support. I couldn’t find much online discussion about dotnet-suggest, so I’m jotting down some notes here.

There are two parts to dotnet-suggest:

We’ll be covering both parts in this blog post, as well as how it all works under the hood. At the end, we’ll look at an interesting way all this functionality is used (or abused?) in C# REPL.

Why would we want to enable dotnet-suggest?

By configuring our system to take advantage of dotnet-suggest, we’ll unlock automatic command line completion for applications built with System.CommandLine. Quite a few applications in the dotnet ecosystem are built with System.CommandLine, and it’s especially prevalent in dotnet global tools. It’s cross-platform and works across Windows, Mac OS, and Linux.

For example, say we’re using the dotnet-trace tool to gather performance traces of a running process. If we’ve configured dotnet-suggest in our shell, we get the following experience:

It’s a nice experience; we have our subcommands, command line flags, and any enumeration values tab-completed for us.

To enable this, we need to do a quick, one-time configuration of our shell:

  1. Install the dotnet-suggest global tool by running the following command:

    > dotnet tool install -g dotnet-suggest
    
  2. Add either this PowerShell snippet or this Bash snippet to our shell configuration file. In PowerShell, our shell configuration file path is available in the $profile variable, and for Bash or ZSH it’s ~/.bash_profile or ~/.zshrc, respectively.

And we’re done! When we use applications written with System.CommandLine, like C# REPL and dotnet-trace, we can enjoy a first-rate tab completion experience.

Next, we’ll look at how to add dotnet-suggest support to our own tools. Spoiler, it’s trivial.

Using System.CommandLine and dotnet-suggest as a developer

We’ll be using System.CommandLine to handle our command line parsing. A full tutorial on this library would get a bit lengthy, so we’ll only cover the very basics needed to add dotnet-suggest support. For a full walkthrough of System.CommandLine, see the README.

Despite System.CommandLine being around for a while now, it’s still listed as pre-release, so we’ll need to install it with the pre-release flag:

> dotnet add package System.CommandLine --prerelease

Next, we’ll use System.CommandLine in our application to define and parse our application’s command line arguments:

  1. Define a root command and its options.
  2. Optionally define subcommands. This is useful when there are different sets of command line options. Using Git as an example, git is a root command, and git clone is a sub-command that takes a different set of options.
  3. Pass your root command to a CommandLineBuilder, which provides a fluent way to add functionality to your command. It can autogenerate --help and --version commands, as well as set up dotnet-suggest integration.
  4. Define a callback that will invoke your app; the parameters of the callback correspond with the command line options you defined in Step 1.
  5. Invoke the command line built by the CommandLineBuilder, providing the args supplied to your program.

A simple yet fully-working application might look like the following. It has autogenerated help and full dotnet-suggest support:

using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;

// define our 3 command line parameters.
// the command can be invoked like MyApp --animal Cat --Emotion Normal "Hello"
var rootCommand = new RootCommand("a little greeter app")
{
    new Option<string>("--animal",
        getDefaultValue: () => "Cat",
        description: "Which animal should say the message"
    )
    // we have suggestions for the animal, but do not constrain
    // it to only these options. We could also pass a function
    // to dynamically generate the suggestions.
    .AddSuggestions("Cat", "Dog", "Velociraptor"),

    // by using an enum, we constrain the options. We could also use
    // FromAmong(), similar to AddSuggestions() above, if we wanted
    // to constrain with Strings instead of an Enum.
    new Option<Emotion>("--emotion",
        getDefaultValue: () => Emotion.Normal,
        description: "How excited they should be when saying the message"
    ),

    // This is a positional argument; no command line flag is required.
    new Argument<string>(
        "Message",
        getDefaultValue: () => "Hello",
        description: "The message to say"
    )
};

// define our actual application. The callback arguments match the
// options and arguments defined above.
rootCommand.Handler = CommandHandler.Create(
    (string animal, Emotion emotion, string message) =>
    {
        var output = $@"The {animal} says {message}"
            + emotion switch
            {
                Emotion.Normal => ".",
                Emotion.Excited => "!",
                Emotion.Ecstatic => "!!!!!!!!!!"
            };
        Console.WriteLine(output);
    }
);

// set up common functionality like --help, --version, and dotnet-suggest support
var commandLine = new CommandLineBuilder(rootCommand)
    .UseDefaults() // automatically configures dotnet-suggest
    .Build();

// invokes our handler callback and actually runs our application
await commandLine.InvokeAsync(args);

enum Emotion
{
    Normal, Excited, Ecstatic
}

The first time we run this program, the program will register itself with dotnet-suggest. Subsequent terminal windows will then be able to take advantage of the dotnet-suggest support automatically, assuming we’ve done the shell setup earlier in this post.

From a development perspective, we’re done! That’s all we have to do to enable dotnet-suggest in our application. For the remainder of this blog post, we’ll look into what’s going on “under the hood.”

How dotnet-suggest works

Ultimately, dotnet-suggest uses shell-specific functionality to provide its autocompletions. In PowerShell, for example, it uses Register-ArgumentCompleter. In this section, we’ll see how dotnet-suggest determines the completions it provides to these shell-specific hooks, but we won’t actually go into the shell-specific functionality.

In our above program, we called .UseDefaults(). This function in turn called the following two functions (among others):

.RegisterWithDotnetSuggest()
.UseSuggestDirective()

Once we understand both these functions, we’ll fully understand how dotnet-suggest works!

RegisterWithDotnetSuggest()

As the name implies, this line will register our application with dotnet-suggest. Applications that are .NET Global Tools will be automatically discovered (by nature of being in the .NET Global Tool installation directory), but this line is needed when running our own binaries elsewhere on the filesystem.

Registration happens by writing to the ~/.dotnet-suggest-registration.txt file. This file is simply a list of executables and their paths. It’s read by the code snippet we put in our shell profile, so dotnet-suggest doesn’t try to autocomplete every application on our system; only the ones that actually support it.

This registration only happens once; when registration is complete a file will be written to our filesystem, and future registrations will be skipped if this file already exists. On Windows, this file is in ~/AppData/Local/Temp/system-commandline-sentinel-files. More generally, it’s in the path returned by Path.GetTempPath().

UseSuggestDirective()

This function allows dotnet-suggest to query our application for available commandline options. dotnet-suggest will send queries to our application as special command line parameters, and our application responds by writing to stdout (i.e. it uses Console.WriteLine).

We can see how this works by pretending to be dotnet-suggest and sending our own command line parameter queries. We’ll use what System.CommandLine calls a “directive” which is just a keyword surrounded by square brackets, used as in-band signalling:

> .\MyApp.exe [suggest]
--animal
--emotion
--help
--version
-?
-h
/?
/h

We sent the [suggest] directive, and our application returned the list of supported command line parameters. This is why we needed to implement our program as a callback function, so System.CommandLine could “own” the pipeline, and insert its own middleware.

We can also test completing substrings. Here, we’ll ask for completions of the string “--” when our caret position is at index 2:

> .\MyApp.exe [suggest:2] "--"
--animal
--emotion
--help
--version

Asking for option values works the same way; here we typed --animal and the only completion that makes sense would be the required type of animal:

> .\MyApp.exe [suggest:9] "--animal "
Cat
Dog
Velociraptor

So, that about sums up how dotnet-suggest works. We register our application (or it’s auto registered), dotnet-suggest queries our application for available completions, and then uses our shell’s tab completion facility to supply these when we’re typing.

A fun use of dotnet-suggest in C# REPL

The fact that dotnet-suggest will query our application for each tab completion request is pretty cool; it unlocks some interesting possibilities.

One neat usage in C# REPL is for the --using command line parameter. This parameter allows you to supply one or more C# namespaces to be included on startup of the REPL. For example, you might want to start the REPL with both System.Collections.Immutable and System.IO.Pipes. Since we can define a delegate to supply suggestions, we can easily allow tab completion of .NET namespaces from the command line!

Another place I found it useful was for the --framework command line option; this parameter needs to be a .NET Shared Framework that is installed on the local computer. Rather than making the user go figure out what shared frameworks are locally installed, C# REPL can simply query on behalf of the user, and allow them to be easily tab-completed.

tagged as csharp, dotnet, cli and csharprepl