DEV Community

Luca Barbato
Luca Barbato

Posted on

winnow-args: yet another args parser

winnow-args is a command line parser for Rust, built on winnow.

Why another one

There are plenty of great crates already, why adding one more given that you have clap, bpaf and usage?

All of them are quite good for different purposes:

  • clap is pretty much the standard, quite user-friendly, but bit slow and not as flexible as you want once you need to parse something more than the usual --long, -l options.
  • bpaf is way more flexible, but not built for speed.
  • usage-rs has a very interesting approach to get the best performance while having as many features as clap.

Sadly the 3 do not have at the same time the flexibility and speed I need.

Speed on the cli?

In brush, every builtin is a small command line: set -eu, declare -x NAME=value, printf -v var '%s' x.

A script runs thousands of them, so the parser runs thousands of times in one process, and a parser that builds its
description of the command at each call is paid for at each call.

Builtins also have to support patterns that are not so common, e.g: set +x turns off what set -x turns on.

I'm using brush extensively in my portage rewrite to parse ebuilds, anything that gets me a bit of speed is welcome.

How can a cli be annoyingly irregular?

One of the most annoying and yet widely used cli syntax is the one used by the GNU linker and thus supported by lld, mold and wild.

Since I started looking at supporting more special pattern I decided to tackle also that. Sadly the mold author prefers the current bespoke solution at least so far.

Here few examples on how irregular it can be:

ld -shared -o out.so --as-needed -lc a.o -z now
Enter fullscreen mode Exit fullscreen mode
  • long options with one dash: -shared
  • short options with arguments: -o and -z
  • flags whose order matters: --as-needed applies to the libraries after it.

And it can get even more complex.

winnow-args

Under the hood it leverages winnow zero-copy combinators over a slice of &OsStr, that maps neatly with the native BStr provided.

To make it easier to use a derive is provided in a fashion similar to the other crates.

  • A struct is a command line.
  • A field in the struct is a flag or a positional, its doc comment is its help.
  • Enums can be used to express possible values or subcommands.

Here some examples:

mini-mise

This example uses subcommands, aliases, dynamic custom values, examples, runtime completion support and colored help.

use winnow_args::color::{Color, Paint, Palette, Theme};
use winnow_args::complete::{Candidate, Shell};
use winnow_args::{Args, Subcommand, ValueEnum, report_with};

/// Install and run development tools
#[derive(Args, Debug)]
#[arg(
    name = "tool",
    version,
    default_subcommand = "use",
    example("tool use -g node@22", header = "Install a tool for every project"),
    example = "tool ls --json"
)]
struct Cli {
    /// Show more (repeat for more)
    #[arg(short, long, count, global)]
    verbose: u8,
    /// When to use color
    #[arg(long, global, default = "auto")]
    color: When,
    #[arg(subcommand)]
    command: Command,
}

#[derive(ValueEnum, Debug, Clone, Copy)]
enum When {
    Auto,
    Always,
    Never,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Install a tool and add it to the project
    #[arg(alias = "u", example = "tool use node@22 python@3.13")]
    Use {
        /// Use the global config instead of the project's
        #[arg(short, long)]
        global: bool,
        /// How many downloads run at once
        #[arg(short, long, default = "4", env = "TOOL_JOBS")]
        jobs: u32,
        /// What to install, as `name@version`
        #[arg(positional, required, complete = tools)]
        tools: Vec<String>,
    },
    /// List installed tools
    Ls {
        /// Print JSON
        #[arg(long)]
        json: bool,
    },
    /// Remove a tool
    #[arg(alias = "rm")]
    Remove {
        /// The tool to remove, as `name@version`
        #[arg(positional, complete = tools)]
        tool: String,
        /// Remove it even if a project uses it
        #[arg(short, long, warning = "Projects that pin it will stop working.")]
        force: bool,
    },
    /// Print a completion script
    Completion {
        #[arg(positional)]
        shell: Shell,
    },
}

/// What a shell offers for a tool, given what is typed of it.
fn tools(typed: &str) -> Vec<Candidate> {
    ["node@20", "node@22", "python@3.13", "ruby@3.4", "zig@0.15"]
        .into_iter()
        .filter(|tool| tool.starts_with(typed))
        .map(|tool| Candidate {
            value: tool.to_owned(),
            help: "",
        })
        .collect()
}

fn main() {
    let args: Vec<_> = std::env::args_os().skip(1).collect();
    if let Some(answer) = Cli::completion_request(&args) {
        print!("{answer}");
        return;
    }
    let cli = match Cli::parse_from(&args) {
        Ok(cli) => cli,
        // Help and version end up here too: `report_with` prints and says how to exit. The theme() implementation is further below.
        Err(error) => std::process::exit(report_with(&error, "tool", &theme())),
    };
    if let Command::Completion { shell } = cli.command {
        print!("{}", Cli::completion_script(shell));
        return;
    }
    println!("{cli:#?}");
}
Enter fullscreen mode Exit fullscreen mode

Help, from the doc comments

-h is the summary and --help the whole text. A subcommand's page lists the global flags it
inherits, a flag can carry a warning, and the examples are declared, not pasted into a string.

Palettes and themes

Help and errors are painted from a palette, and the terminal says how deep it goes: no color in a pipe or under NO_COLOR, the 16 basic colors, 256, or 24 bits.

A theme is a palette for each depth.
The default one, on a 16-color and on a 256-color terminal:

With NO_COLOR=1, or in a pipe:

A program brings its own with a few lines. Here one palette for 24-bit terminals, and clap's look for people who are used to it:

/// The paints of help and errors: `TOOL_THEME` picks one.
fn theme() -> Theme {
    const fn rgb(r: u8, g: u8, b: u8) -> Paint {
        Paint::fg(Color::Rgb(r, g, b))
    }
    // On a 24-bit terminal only; the others keep the default palettes.
    const SUNSET: Palette = Palette {
        header: rgb(255, 158, 100).bold(),
        flag: rgb(224, 175, 104).bold(),
        command: rgb(224, 175, 104).bold(),
        placeholder: rgb(187, 154, 247),
        default: rgb(247, 118, 142),
        choice: rgb(247, 118, 142),
        ..Palette::DEFAULT_256
    };
    match std::env::var("TOOL_THEME").as_deref() {
        // clap's own look: bold and underline, color only in errors.
        Ok("clap") => Theme {
            ansi16: Palette::CLAP,
            ansi256: None,
            truecolor: None,
        },
        Ok("sunset") => Theme {
            truecolor: Some(SUNSET),
            ..Theme::DEFAULT
        },
        _ => Theme::DEFAULT,
    }
}
Enter fullscreen mode Exit fullscreen mode

Extended error reporting

Completion that asks the program

tool completion fish prints a short script that calls the program back, so the shell always offers what this build of the program takes.

Flags come with their descriptions, a value with its choices, and complete = tools above hands the question to a function of yours.

bash, zsh, fish, elvish and PowerShell are supported.

Exotic syntaxes

  • -eu +x is three fields of type Option<bool>, each declared #[arg(short = 'e', plus = 'e')].

The linker's flags are an enum, and the struct holds them in the order they were given.

Under the hood

The derive produces the following:

  • the parser
  • the help as static data
  • optionally a toml description.

At run time the words go through a small lexer, the generated parser binds each to its field, FromArg turns a word into a
value, and a last step applies the environment, the defaults and the rules between fields.

Help, the tips in errors and completion all read the same static data, and only when needed.

The toml descriptions are needed by the documentation builders. They read the files the derive writes on request and they do not depend on running the code generated for the target architecture.

Key features

  • The parser is generated at compile time. The derive writes a match over the flags of your struct. Nothing is assebled when the program starts, so a program with two hundred subcommands starts parsing as fast as one with two.
  • Words are borrowed. Nothing is copied or re-split, and the bytes need not be UTF-8 until a field's type asks for text.
  • Mistakes in the declaration are compile errors. A conflicts("nme") that names no field, two flags with the same letter, a positional after a list that takes every word: the build fails.
  • Help is data. The prose sits in read-only memory until --help asks for it, and a feature leaves it out of the binary altogether if it is not needed.

Everything is a winnow parser, so you can build your own by combining the basic parsers together manually.

Performance

winnow-args is built essentially for speed first, comfort as close second.

The repository has two benchmarks used to make sure adding features won't cause slow downs.

It compares with the other argument parsers I normally use:

a small CLI mise's 211 commands
winnow-args 187 ns 327 ns
usage 7 516 ns 767 ns
clap 4 15.0 µs 757 µs
bpaf 0.9 14.8 µs 2.65 ms

The same command lines are written once per framework in the repository's benchmarks/.

A program that parses once will not feel the difference with clap on a small command line. A shell does, and so does a tool with as many subcommands as mise.

Implemented features

A good deal of features are already implemented, but some found on more mature parsers are still missing.

winnow-args usage clap
Flags, values, positionals, subcommands, global flags, flatten ✓ ✓ ✓
Conflicts, requirements, groups ✓ ✓ ✓
Help from doc comments ✓ ✓ ✓
Completion scripts ✓ ✓ ~
Parser generated at compile time, words borrowed ✓ ✓ –
Combinators, without a macro ✓ – ~
+x options, as a shell builtin takes ✓ – –
Long options with one dash, -z keywords, as GNU ld takes ✓ – –
Flags kept in command-line order ✓ ~ ~
Flag constraints checked at compile time ✓ – –
Help prose left out of the binary by a feature ✓ ~ –
A CLI built at runtime, external subcommands – ✓ ✓
Conditional requirements (required_if) – ✓ ✓
Man pages, markdown ~ ✓ ~
"Did you mean" suggestions ✓ ✓ ✓

winnow-args-man and winnow-args-markdown render one page per command.

They do not run the program to build it:

  • when WINNOW_ARGS_SPEC set, the derives write a description of each type while cargo check or cargo build expands them.
  • the pages are built from those files.

So man pages can be built correctly even when cross compiling.

In closing

With version 0.3.0 winnow-args graduated from being a quick experiment to see how far I could push the parsing performance and now it can be used to deal with the quirks of complex CLIs.

Some features that I didn't need to support brush and portage-cli em are still missing and I have plans to add a more flexible generator based on minijinja, but I welcome you to try it and possibly provide feedback and pull requests to improve it further.

Top comments (0)