winnow-args 0.3.0

Command line argument parsing built from winnow parsers
Documentation
//! Positional arguments, run against the combinator parser and the derived
//! one: both must agree on every command line.

use winnow::combinator::alt;
use winnow::prelude::*;
use winnow::stream::BStr;
use winnow_args::combinator::{Named, args, positional, short};
use winnow_args::{Args, Argv, Error, ErrorKind};

#[derive(Debug, PartialEq, Default)]
struct Cli {
    verbose: bool,
    path: Option<String>,
    input: String,
    output: Option<String>,
    rest: Vec<String>,
}

const VERBOSE: Named = short('v').long("verbose");
const PATH: Named = short('p').long("path");

fn combinator(input: &mut Argv<'_>) -> Result<Cli, Error> {
    let mut cli = Cli::default();
    let mut first = None;
    let mut position = 0;
    args(alt((
        VERBOSE.switch().map(|()| cli.verbose = true),
        PATH.argument_as().map(|p| cli.path = Some(p)),
        positional::<String>("WORD").map(|word| {
            match position {
                0 => first = Some(word),
                1 => cli.output = Some(word),
                _ => cli.rest.push(word),
            }
            position += 1;
        }),
    )))
    .parse_next(input)?;
    cli.input = first.ok_or_else(|| Error::missing_argument(input.offset(), "INPUT"))?;
    Ok(cli)
}

#[derive(Args, Debug, PartialEq)]
struct Derived {
    #[arg(short, long)]
    verbose: bool,
    #[arg(short, long)]
    path: Option<String>,
    #[arg(positional)]
    input: String,
    #[arg(positional)]
    output: Option<String>,
    #[arg(positional)]
    rest: Vec<String>,
}

impl From<Derived> for Cli {
    fn from(d: Derived) -> Self {
        let Derived {
            verbose,
            path,
            input,
            output,
            rest,
        } = d;
        Cli {
            verbose,
            path,
            input,
            output,
            rest,
        }
    }
}

fn parse(line: &[&str]) -> Result<Cli, Error> {
    let words = crate::words(line);
    let a = combinator.parse_next(&mut Argv::new(&words));
    let b = Derived::parse_words(&words).map(Cli::from);
    assert_eq!(a, b, "combinator and derive disagree on {line:?}");
    a
}

fn ok(line: &[&str]) -> Cli {
    parse(line).unwrap_or_else(|e| panic!("{line:?}: {e}"))
}

fn err(line: &[&str]) -> Error {
    match parse(line) {
        Ok(cli) => panic!("{line:?} parsed as {cli:?}"),
        Err(e) => e,
    }
}

fn strings(words: &[&str]) -> Vec<String> {
    words.iter().map(|w| w.to_string()).collect()
}

#[test]
fn fill_in_declaration_order() {
    let cli = ok(&["a"]);
    assert_eq!(
        (cli.input.as_str(), cli.output, cli.rest),
        ("a", None, vec![])
    );

    let cli = ok(&["a", "b", "c", "d"]);
    assert_eq!(cli.input, "a");
    assert_eq!(cli.output.as_deref(), Some("b"));
    assert_eq!(cli.rest, strings(&["c", "d"]));
}

#[test]
fn interleave_with_flags() {
    let cli = ok(&["a", "-v", "b", "--path=x", "c"]);
    assert!(cli.verbose);
    assert_eq!(cli.path.as_deref(), Some("x"));
    assert_eq!(
        (cli.input.as_str(), cli.output.as_deref()),
        ("a", Some("b"))
    );
    assert_eq!(cli.rest, strings(&["c"]));

    // A detached value belongs to its flag, not to the positionals.
    let cli = ok(&["--path", "a", "b"]);
    assert_eq!((cli.path.as_deref(), cli.input.as_str()), (Some("a"), "b"));
}

#[test]
fn words_that_look_like_flags() {
    assert_eq!(ok(&["-"]).input, "-");
    let cli = ok(&["a", "--", "--path", "-v"]);
    assert!(!cli.verbose);
    assert_eq!(cli.output.as_deref(), Some("--path"));
    assert_eq!(cli.rest, strings(&["-v"]));
    assert_eq!(ok(&["--", "--"]).input, "--");
}

#[test]
fn errors() {
    let e = err(&["-v"]);
    assert_eq!(
        (e.kind(), e.token()),
        (ErrorKind::MissingArgument, Some("INPUT"))
    );
    assert_eq!(err(&["a", "--nope"]).kind(), ErrorKind::UnknownFlag);
}

#[derive(Args, Debug)]
struct One {
    #[arg(positional, value_name = "N")]
    n: u32,
}

#[test]
fn extra_and_invalid_words() {
    assert_eq!(One::parse_words(&[BStr::new("7")]).unwrap().n, 7);

    let words = [BStr::new("1"), BStr::new("2")];
    let e = One::parse_words(&words).unwrap_err();
    assert_eq!(
        (e.kind(), e.token(), e.offset()),
        (ErrorKind::UnexpectedArg, Some("2"), 2)
    );

    let words = [BStr::new("x")];
    let e = One::parse_words(&words).unwrap_err();
    assert_eq!(
        (e.kind(), e.token(), e.value()),
        (ErrorKind::InvalidValue, Some("N"), Some("x"))
    );
}