#cli #command #clap #derive

type-cli

A convenient, strongly-typed CLI parser

2 releases

0.0.3 Dec 13, 2020
0.0.2 Dec 13, 2020
0.0.1 Dec 13, 2020

#132 in Command-line interface

23 downloads per month

MIT license

31KB
774 lines

type-cli

type-cli is a convenient, strongly-typed command-line interface parser.

To start, let's create an interface for grep.

Basics

use type_cli::CLI;

#[derive(CLI)]
struct Grep(String, String);

fn main() {
    let Grep(pattern, file) = Grep::process();
    let pattern = regex::Regex::new(&pattern).unwrap();

    eprintln!("Searching for `{}` in {}", pattern, file);
}

Now, if we run the binary with arguments, they'll be properly parsed. And if we miss an argument, it'll give a helpful error.

$ grep foo* myFile
Searching for `foo*` in myFile

$ grep foo*
Expected an argument at position `2`

However, this isn't exactly a faithful grep interface: in grep, the file is optional. Plus, that unwrap() is a little gross.

use type_cli::CLI;

#[derive(CLI)]
struct Grep(regex::Regex, #[optional] Option<String>);

fn main() {
    match Grep::process() {
        Grep(pattern, Some(file)) => eprintln!("Serching for `{}` in {}", pattern, file),
        Grep(pattern, None) => eprintln!("Searching for `{}` in stdin", pattern),
    }
}

What's that? We're accepting a Regex directly as an argument? In type-cli, any type that implements FromStr can be an argument. Any parsing errors will be gracefully passed back to the user without you having to worry about it.

$ grep foo(
Error parsing positional argument `1`:
regex parse error:
    foo(
       ^
error: unclosed group

Here, you can also see that optional arguments must be annotated with #[optional].

$ grep foo* myFile
Serching for `foo*` in myFile

$ grep foo*
Searching for `foo*` in stdin

This interface still isn't faithful though; grep allows multiple files to be searched.

use type_cli::CLI;

#[derive(CLI)]
struct Grep(regex::Regex, #[variadic] Vec<String>);

fn main(){
    let Grep(pattern, file_list) = Grep::process();
    if file_list.is_empty() {
        eprintln!("Searching for `{}` in stdin", pattern);
    } else {
        eprint!("Searching for `{}` in ", pattern);
        file_list.iter().for_each(|f| eprint!("{}, ", f));
    }
}

If you annote the final field with #[variadic], it will parse an arbitrary number of arguments. This works for any collection that implements FromIterator.

$ grep foo*
Searching for `foo*` in stdin

$grep foo* myFile yourFile ourFile
Searching for `foo*` in myFile, yourFile, ourFile,

This still isn't ideal, though. None of the fields have names, and there's no flags or options! Clearly, tuple structs are limiting us.

Named arguments and flags

use type_cli::CLI;

#[derive(CLI)]
struct Grep {
    pattern: regex::Regex,

    #[named]
    file: String,

    #[flag(short = "i")]
    ignore_case: bool,
}

fn main() {
    let Grep { pattern, file, ignore_case } = Grep::process();
    eprint!("Searching for `{}` in {}", pattern, file);
    if ignore_case {
        eprint!(", ignoring case");
    }
    eprintln!();
}

Named arguments are annoted with #[named], and that allows them to be passed to the command in any order. By default, named arguments are still required, but they can also be marked with #[optional].

$ grep foo*
Expected an argument named `--file`

$ grep foo* --file myFile
Searching for `foo*` in myFile

Flags are annoted with #[flag], and are completely optional boolean or integer flags. You can optionally specify a shorter form with #[flag(short = "a")] (this form also works for named arguments).

$ grep foo* --file myFile --ignore-case
Searching for `foo*` in myFile, ignoring case

$ grep foo* --file myFile -i
Searching for `foo*` in myFile, ignoring case

This seems well and good, but what if I want multiple commands in my application?

Subcommands

use type_cli::CLI;

#[derive(CLI)]
enum Cargo {
    New(String),
    Build {
        #[named] #[optional]
        target: Option<String>,
        #[flag]
        release: bool,
    },
    Clippy {
        #[flag]
        pedantic: bool,
    }
}

fn main() {
    match Cargo::process() {
        Cargo::New(name) => eprintln!("Creating new crate `{}`", name),
        Cargo::Build { target, release } => {
            let target = target.as_deref().unwrap_or("windows");
            if release {
                eprintln!("Building for {} in release", target);
            } else {
                eprintln!("Building for {}", target);
            }
        }
        Cargo::Clippy { pedantic: true } => eprintln!("Annoyingly checking your code."),
        Cargo::Clippy { pedantic: false } => eprintln!("Checking your code."),
    }
}

If you derive CLI on an enum, each variant will represent a subcommand. Each subcommand is parsed with the same syntax as before.

Rust's pascal case will be automatically converted to the standard for shells: SubCommand -> sub-command

$ cargo new myCrate
Creating new crate `myCrate`

$ cargo build
Building for windows

$ cargo build --target linux
Building for linux

$ cargo build --target linux --release
Building for linux in release

$ cargo clippy
Checking your code.

$ cargo clippy --pedantic
Annoyingly checking your code.

What about documentation?

--help

use type_cli::CLI;

#[derive(CLI)]
#[help = "Build manager tool for rust"]
enum Cargo {
    New(String),

    #[help = "Build the current crate."]
    Build {
        #[named] #[optional]
        #[help = "the target platform"]
        target: Option<String>,

        #[flag]
        #[help = "build for release mode"]
        release: bool,
    },

    #[help = "Lint your code"]
    Clippy {
        #[flag]
        #[help = "include annoying and subjective lints"]
        pedantic: bool,
    }
}

type-cli will automatically generate a help screen for your commands. If you annote a subcommand or argument with #[help = ""], it will include your short description. When shown, it will be sent to stderr and the process will exit with a nonzero status.

$ cargo
Help - cargo
Build manager tool for rust

SUBCOMMANDS:
    new
    build       Build the current crate.
    clippy      Lint your code

For enums, this will be shown if the command is called without specifying a subcommand.

$ cargo build --help
Help - build
Build the current crate.

ARGUMENTS:
    --target    the target platform     [optional]

FLAGS:
    --release   build for release mode


$ cargo clippy -h
Help - clippy
Lint your code

FLAGS:
    --pedantic  include annoying and subjective lints

For structs or subcommands, this will be called if the flag --help or -h is passed. Help messages are not currently supported for tuple structs.

Dependencies

~1.7–2.7MB
~70K SLoC