#![forbid(unsafe_code)]
use std::ffi::OsStr;
pub const MAX_DEPTH: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Command<'a> {
pub name: &'a str,
pub aliases: &'a [&'a str],
pub flags: &'a [&'a Flag<'a>],
pub args: &'a [&'a Arg<'a>],
pub subcommands: &'a [&'a Command<'a>],
pub key: u32,
}
impl Command<'_> {
pub const EMPTY: Command<'static> = Command {
name: "",
aliases: &[],
flags: &[],
args: &[],
subcommands: &[],
key: 0,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Flag<'a> {
pub key: u32,
pub name: &'a str,
pub longs: &'a [&'a str],
pub shorts: &'a [u8],
pub negate: Option<&'a str>,
pub takes_value: bool,
pub var: bool,
pub global: bool,
}
impl Flag<'_> {
pub const BOOL: Flag<'static> = Flag {
key: 0,
name: "",
longs: &[],
shorts: &[],
negate: None,
takes_value: false,
var: false,
global: false,
};
pub const VALUE: Flag<'static> = Flag {
takes_value: true,
..Flag::BOOL
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Arg<'a> {
pub key: u32,
pub var: bool,
pub double_dash: DoubleDash,
pub name: &'a str,
}
impl Arg<'_> {
pub const REQUIRED: Arg<'static> = Arg {
key: 0,
var: false,
double_dash: DoubleDash::Optional,
name: "",
};
pub const VAR: Arg<'static> = Arg {
var: true,
..Arg::REQUIRED
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DoubleDash {
#[default]
Optional,
Required,
Preserve,
Automatic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event<'t, 'v> {
Command(&'t Command<'t>),
Flag {
flag: &'t Flag<'t>,
value: Option<&'v [u8]>,
negated: bool,
},
Arg { arg: &'t Arg<'t>, value: &'v [u8] },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error<'t, 'v> {
UnknownFlag { token: &'v [u8] },
MissingFlagValue { flag: &'t Flag<'t> },
UnexpectedArg { token: &'v [u8] },
ArgRequiresDoubleDash { arg: &'t Arg<'t> },
TooDeep,
}
pub fn as_str(value: &[u8]) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(value)
}
pub struct Parser<'t, 'v> {
argv: &'v [&'v OsStr],
pos: usize,
cmd: &'t Command<'t>,
ancestors: [Option<&'t Command<'t>>; MAX_DEPTH],
depth: usize,
bundle: &'v [u8],
bundle_token: &'v [u8],
collecting: Option<&'t Flag<'t>>,
arg_pos: usize,
arg_filled: bool,
double_dash: bool,
done: bool,
}
impl<'t, 'v> Parser<'t, 'v> {
pub fn new(root: &'t Command<'t>, argv: &'v [&'v OsStr]) -> Self {
Parser {
argv,
pos: 0,
cmd: root,
ancestors: [None; MAX_DEPTH],
depth: 0,
bundle: &[],
bundle_token: &[],
collecting: None,
arg_pos: 0,
arg_filled: false,
double_dash: false,
done: false,
}
}
pub fn command(&self) -> &'t Command<'t> {
self.cmd
}
pub fn double_dash_seen(&self) -> bool {
self.double_dash
}
#[allow(clippy::should_implement_trait)] pub fn next_event(&mut self) -> Option<Result<Event<'t, 'v>, Error<'t, 'v>>> {
if self.done {
return None;
}
let event = self.step();
if let Some(Err(_)) = event {
self.done = true;
}
event
}
fn step(&mut self) -> Option<Result<Event<'t, 'v>, Error<'t, 'v>>> {
if !self.bundle.is_empty() {
return Some(self.short_flag());
}
if let Some(flag) = self.collecting {
match self.argv.get(self.pos) {
Some(next) if !is_flag_like(bytes(next)) && bytes(next) != b"--" => {
self.pos += 1;
return Some(Ok(Event::Flag {
flag,
value: Some(bytes(next)),
negated: false,
}));
}
_ => self.collecting = None,
}
}
let token = bytes(self.argv.get(self.pos)?);
self.pos += 1;
if self.double_dash {
return Some(self.word(token));
}
if token == b"--" {
if self
.next_arg()
.is_some_and(|a| a.double_dash == DoubleDash::Preserve)
{
return Some(self.word(token));
}
self.double_dash = true;
if let Some(idx) = self.cmd.args[self.arg_pos..]
.iter()
.position(|a| a.double_dash == DoubleDash::Required)
{
self.arg_pos += idx;
}
return self.step();
}
if is_flag_like(token) {
if token.starts_with(b"--") {
return Some(self.long_flag(token));
}
if let Err(e) = self.check_bundle(token) {
return Some(Err(e));
}
self.bundle = &token[1..];
self.bundle_token = token;
return Some(self.short_flag());
}
Some(self.word(token))
}
fn long_flag(&mut self, token: &'v [u8]) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
let body = &token[2..];
let (name, attached) = match body.iter().position(|&b| b == b'=') {
Some(i) => (&body[..i], Some(&body[i + 1..])),
None => (body, None),
};
if let Some(flag) = self.find_long(name) {
let value = if flag.takes_value {
Some(match attached {
Some(v) => v,
None => self.take_detached_value(flag)?,
})
} else {
None
};
if flag.var {
self.collecting = Some(flag);
}
return Ok(Event::Flag {
flag,
value,
negated: false,
});
}
if let Some(flag) = self.find_negation(name) {
return Ok(Event::Flag {
flag,
value: None,
negated: true,
});
}
Err(Error::UnknownFlag { token })
}
fn check_bundle(&self, token: &'v [u8]) -> Result<(), Error<'t, 'v>> {
let mut rest = &token[1..];
while let Some((&byte, tail)) = rest.split_first() {
match self.find_short(byte) {
None => return Err(Error::UnknownFlag { token }),
Some(flag) if flag.takes_value => return Ok(()),
Some(_) => rest = tail,
}
}
Ok(())
}
fn short_flag(&mut self) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
let byte = self.bundle[0];
let rest = &self.bundle[1..];
let Some(flag) = self.find_short(byte) else {
self.bundle = &[];
return Err(Error::UnknownFlag {
token: self.bundle_token,
});
};
if !flag.takes_value {
self.bundle = rest;
return Ok(Event::Flag {
flag,
value: None,
negated: false,
});
}
self.bundle = &[];
let value = if rest.is_empty() {
self.take_detached_value(flag)?
} else if rest[0] == b'=' {
&rest[1..]
} else {
rest
};
if flag.var {
self.collecting = Some(flag);
}
Ok(Event::Flag {
flag,
value: Some(value),
negated: false,
})
}
fn take_detached_value(&mut self, flag: &'t Flag<'t>) -> Result<&'v [u8], Error<'t, 'v>> {
match self.argv.get(self.pos) {
Some(next) if !is_flag_like(bytes(next)) => {
self.pos += 1;
Ok(bytes(next))
}
_ => Err(Error::MissingFlagValue { flag }),
}
}
fn word(&mut self, token: &'v [u8]) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
if !self.arg_filled && !self.double_dash {
if let Some(sub) = self.find_subcommand(token) {
self.descend(sub)?;
return Ok(Event::Command(sub));
}
}
let Some(arg) = self.next_arg() else {
return Err(Error::UnexpectedArg { token });
};
if arg.double_dash == DoubleDash::Required && !self.double_dash {
return Err(Error::ArgRequiresDoubleDash { arg });
}
self.arg_filled = true;
if arg.double_dash == DoubleDash::Automatic {
self.double_dash = true;
}
if !arg.var {
self.arg_pos += 1;
}
Ok(Event::Arg { arg, value: token })
}
fn descend(&mut self, sub: &'t Command<'t>) -> Result<(), Error<'t, 'v>> {
if self.depth >= MAX_DEPTH {
return Err(Error::TooDeep);
}
self.ancestors[self.depth] = Some(self.cmd);
self.depth += 1;
self.cmd = sub;
self.arg_pos = 0;
self.arg_filled = false;
Ok(())
}
fn next_arg(&self) -> Option<&'t Arg<'t>> {
self.cmd.args.get(self.arg_pos).copied()
}
fn in_scope(&self) -> impl Iterator<Item = &'t Flag<'t>> + '_ {
let own = self.cmd.flags.iter().copied();
let inherited = self.ancestors[..self.depth]
.iter()
.rev()
.filter_map(|c| *c)
.flat_map(|c| c.flags.iter().copied())
.filter(|f| f.global);
own.chain(inherited)
}
fn find_long(&self, name: &[u8]) -> Option<&'t Flag<'t>> {
self.in_scope()
.find(|f| f.longs.iter().any(|l| l.as_bytes() == name))
}
fn find_negation(&self, name: &[u8]) -> Option<&'t Flag<'t>> {
self.in_scope()
.find(|f| f.negate.is_some_and(|n| n.as_bytes() == name))
}
fn find_short(&self, byte: u8) -> Option<&'t Flag<'t>> {
self.in_scope().find(|f| f.shorts.contains(&byte))
}
fn find_subcommand(&self, name: &[u8]) -> Option<&'t Command<'t>> {
self.cmd
.subcommands
.iter()
.copied()
.find(|c| c.name.as_bytes() == name || c.aliases.iter().any(|a| a.as_bytes() == name))
}
}
fn bytes<'v>(s: &'v &'v OsStr) -> &'v [u8] {
s.as_encoded_bytes()
}
fn is_flag_like(token: &[u8]) -> bool {
match token {
[b'-', rest @ ..] if !rest.is_empty() => !rest[0].is_ascii_digit(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
static FORCE: Flag = Flag {
key: 1,
longs: &["force"],
shorts: b"f",
..Flag::BOOL
};
static JOBS: Flag = Flag {
key: 2,
longs: &["jobs"],
shorts: b"j",
..Flag::VALUE
};
static COLOR: Flag = Flag {
key: 3,
longs: &["color"],
negate: Some("no-color"),
..Flag::BOOL
};
static VERBOSE: Flag = Flag {
key: 4,
longs: &["verbose"],
shorts: b"v",
global: true,
..Flag::BOOL
};
static FILE: Arg = Arg {
key: 10,
name: "file",
..Arg::REQUIRED
};
static REST: Arg = Arg {
key: 11,
name: "rest",
..Arg::VAR
};
static INSTALL: Command = Command {
name: "install",
aliases: &["i"],
flags: &[&FORCE],
key: 100,
..Command::EMPTY
};
static ROOT: Command = Command {
name: "ex",
flags: &[&FORCE, &JOBS, &COLOR, &VERBOSE],
args: &[&FILE, &REST],
subcommands: &[&INSTALL],
..Command::EMPTY
};
fn parse<'t, 'v>(
root: &'t Command<'t>,
argv: &'v [&'v OsStr],
) -> Result<Vec<Event<'t, 'v>>, Error<'t, 'v>> {
let mut parser = Parser::new(root, argv);
let mut events = Vec::new();
while let Some(event) = parser.next_event() {
events.push(event?);
}
Ok(events)
}
fn argv<const N: usize>(tokens: [&str; N]) -> [&OsStr; N] {
tokens.map(OsStr::new)
}
#[test]
fn long_boolean() {
let a = argv(["--force"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![Event::Flag {
flag: &FORCE,
value: None,
negated: false
}]
);
}
#[test]
fn long_value_forms() {
for tokens in [vec!["--jobs=8"], vec!["--jobs", "8"]] {
let a: Vec<&OsStr> = tokens.iter().map(|t| OsStr::new(*t)).collect();
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![Event::Flag {
flag: &JOBS,
value: Some(b"8"),
negated: false
}],
"{tokens:?}"
);
}
}
#[test]
fn long_value_keeps_later_equals() {
let a = argv(["--jobs=a=b"]);
let Event::Flag { value, .. } = parse(&ROOT, &a).unwrap()[0] else {
panic!("expected a flag");
};
assert_eq!(value, Some(&b"a=b"[..]));
}
#[test]
fn long_value_attached_empty_is_empty_not_absent() {
let a = argv(["--jobs="]);
let Event::Flag { value, .. } = parse(&ROOT, &a).unwrap()[0] else {
panic!("expected a flag");
};
assert_eq!(value, Some(&b""[..]));
}
#[test]
fn long_value_refuses_flaglike_next_word() {
let a = argv(["--jobs", "--force"]);
assert_eq!(
parse(&ROOT, &a),
Err(Error::MissingFlagValue { flag: &JOBS })
);
}
#[test]
fn long_value_accepts_negative_number() {
let a = argv(["--jobs", "-1"]);
let Event::Flag { value, .. } = parse(&ROOT, &a).unwrap()[0] else {
panic!("expected a flag");
};
assert_eq!(value, Some(&b"-1"[..]));
}
#[test]
fn no_abbreviation() {
let a = argv(["--forc"]);
assert!(matches!(
parse(&ROOT, &a),
Err(Error::UnknownFlag { token: b"--forc" })
));
}
#[test]
fn negation() {
let a = argv(["--no-color"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![Event::Flag {
flag: &COLOR,
value: None,
negated: true
}]
);
}
#[test]
fn short_bundle_and_attached_value() {
let a = argv(["-fj8"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![
Event::Flag {
flag: &FORCE,
value: None,
negated: false
},
Event::Flag {
flag: &JOBS,
value: Some(b"8"),
negated: false
},
]
);
}
#[test]
fn short_value_strips_one_equals() {
for (tokens, want) in [(["-j=8"], &b"8"[..]), (["-j==8"], &b"=8"[..])] {
let a = argv(tokens);
let Event::Flag { value, .. } = parse(&ROOT, &a).unwrap()[0] else {
panic!("expected a flag");
};
assert_eq!(value, Some(want), "{tokens:?}");
}
}
#[test]
fn bare_dash_is_a_value() {
let a = argv(["-"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![Event::Arg {
arg: &FILE,
value: b"-"
}]
);
}
#[test]
fn positionals_then_variadic() {
let a = argv(["one", "two", "three"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![
Event::Arg {
arg: &FILE,
value: b"one"
},
Event::Arg {
arg: &REST,
value: b"two"
},
Event::Arg {
arg: &REST,
value: b"three"
},
]
);
}
#[test]
fn subcommand_and_alias_route_the_same() {
for token in ["install", "i"] {
let a = argv([token]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![Event::Command(&INSTALL)],
"{token}"
);
}
}
#[test]
fn subcommand_only_routes_before_a_positional_is_filled() {
let a = argv(["other", "install"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![
Event::Arg {
arg: &FILE,
value: b"other"
},
Event::Arg {
arg: &REST,
value: b"install"
},
]
);
}
#[test]
fn globals_are_inherited_but_plain_flags_are_not() {
let a = argv(["install", "--verbose"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![
Event::Command(&INSTALL),
Event::Flag {
flag: &VERBOSE,
value: None,
negated: false
}
]
);
let a = argv(["install", "--jobs", "8"]);
assert!(matches!(parse(&ROOT, &a), Err(Error::UnknownFlag { .. })));
}
#[test]
fn double_dash_protects_flaglike_values() {
let a = argv(["--", "--force", "-x"]);
assert_eq!(
parse(&ROOT, &a).unwrap(),
vec![
Event::Arg {
arg: &FILE,
value: b"--force"
},
Event::Arg {
arg: &REST,
value: b"-x"
},
]
);
}
#[test]
fn second_double_dash_is_a_value() {
let a = argv(["--", "a", "--", "b"]);
let values: Vec<&[u8]> = parse(&ROOT, &a)
.unwrap()
.iter()
.filter_map(|e| match e {
Event::Arg { value, .. } => Some(*value),
_ => None,
})
.collect();
assert_eq!(values, vec![&b"a"[..], &b"--"[..], &b"b"[..]]);
}
#[test]
fn double_dash_required_arg() {
static CMD: Arg = Arg {
key: 20,
name: "cmd",
double_dash: DoubleDash::Required,
..Arg::REQUIRED
};
static EXEC: Command = Command {
name: "ex",
args: &[&CMD],
..Command::EMPTY
};
let a = argv(["--", "ls"]);
assert_eq!(
parse(&EXEC, &a).unwrap(),
vec![Event::Arg {
arg: &CMD,
value: b"ls"
}]
);
let a = argv(["ls"]);
assert_eq!(
parse(&EXEC, &a),
Err(Error::ArgRequiresDoubleDash { arg: &CMD })
);
}
#[test]
fn double_dash_preserve_keeps_the_separator() {
static ARGS: Arg = Arg {
key: 21,
name: "args",
double_dash: DoubleDash::Preserve,
..Arg::VAR
};
static WRAP: Command = Command {
name: "ex",
args: &[&ARGS],
..Command::EMPTY
};
let a = argv(["a", "--", "b"]);
let values: Vec<&[u8]> = parse(&WRAP, &a)
.unwrap()
.iter()
.filter_map(|e| match e {
Event::Arg { value, .. } => Some(*value),
_ => None,
})
.collect();
assert_eq!(values, vec![&b"a"[..], &b"--"[..], &b"b"[..]]);
}
#[test]
fn double_dash_automatic_stops_flag_interpretation() {
static FILES: Arg = Arg {
key: 22,
name: "files",
double_dash: DoubleDash::Automatic,
..Arg::VAR
};
static AUTO: Command = Command {
name: "ex",
flags: &[&FORCE],
args: &[&FILES],
..Command::EMPTY
};
let a = argv(["-f", "one", "--force"]);
assert_eq!(
parse(&AUTO, &a).unwrap(),
vec![
Event::Flag {
flag: &FORCE,
value: None,
negated: false
},
Event::Arg {
arg: &FILES,
value: b"one"
},
Event::Arg {
arg: &FILES,
value: b"--force"
},
]
);
}
#[test]
fn too_many_words() {
static ONE: Command = Command {
name: "ex",
args: &[&FILE],
..Command::EMPTY
};
let a = argv(["a", "b"]);
assert_eq!(parse(&ONE, &a), Err(Error::UnexpectedArg { token: b"b" }));
}
#[test]
fn unknown_letter_rejects_the_whole_bundle() {
let a = argv(["-fz"]);
let mut parser = Parser::new(&ROOT, &a);
assert_eq!(
parser.next_event(),
Some(Err(Error::UnknownFlag { token: b"-fz" })),
"an unknown letter must reject the token before any of it is applied"
);
assert!(parser.next_event().is_none());
}
#[test]
fn unknown_short_error_names_the_whole_token() {
for (tokens, want) in [(["-z"], &b"-z"[..]), (["-fz"], &b"-fz"[..])] {
let a = argv(tokens);
assert_eq!(
parse(&ROOT, &a),
Err(Error::UnknownFlag { token: want }),
"{tokens:?}"
);
}
}
#[test]
fn errors_are_terminal() {
let a = argv(["--wat", "--force"]);
let mut parser = Parser::new(&ROOT, &a);
assert!(parser.next_event().unwrap().is_err());
assert!(parser.next_event().is_none());
}
#[test]
fn non_utf8_values_still_parse() {
let raw = OsStr::new("--force");
let a = [raw];
assert!(parse(&ROOT, &a).is_ok());
assert!(as_str(b"ok").is_ok());
assert!(as_str(&[0xff, 0xfe]).is_err());
}
}