-
Notifications
You must be signed in to change notification settings - Fork 6
Description
Recently, I simplified the handling of positional arguments so that there is always a Vec
with all positional arguments. While I think that was the right choice, we can still make that nicer to work with. In this issue, I want to explore that a bit.
Let's start with the status quo. The main function in most apps now looks something like this:
let (settings, operands) = Settings::default().parse()?;
However, those operands still need to be unpacked to what the util expects:
arch
doesn't accept any positional arguments.base32
only accepts a single positional argument.cat
accepts any number of positional arguments.cp
needs to separate the destination from the sources.join
has 2 optional arguments
So how do we do that?
Method 1: Matching
// arch
if !operands.is_empty() {
// some error
}
// base32
let file = match &operands {
[] => return Err(/* not enough arguments */),
[file] => file,
_ => return Err(/* too many arguments */),
}
// cat
if operands.is_empty() {
operands.push(OsString::from("-"));
}
// cp (more complicated in reality because it depends on options)
let (sources, dest) = match &operands {
[] => return Err(/* missing sources */),
[_] => return Err(/* missing destination */),
[sources@.., dest] => (sources, dest),
}
This is pretty nice, but it means that the errors are entirely the responsibility of the utility, with no help from this library. It's also easy to forget to check the operands in arch
, when you do not need them. The second arm of the match
expression is also interesting, because Rust will nog force us to include it.
Method 2: An Operands
Type
We could instead define a wrapper around Vec
called Operands
:
// arch
operands.empty()?;
// base32
let file = operands.pop_front("FILE")?;
operands.empty()?;
// cat
let files = operands.to_vec();
if files.is_empty() {
files.push(OsString::from("-"));
}
// cp
let destination = operands.pop_back("DEST")?;
let sources = operands.to_non_empty_vec("SOURCES")?;
// join
let file1 = operands.pop_front("FILE1").ok();
let file2 = operands.pop_front("FILE2").ok();
operands.empty()?;
This is fairly concise and could provide pretty good error messages out of the box. It's not very declarative though. It would also benefit from linear types, which we don't have unfortunately.
Method 3: Include all the possibilities!
So, there's an advantage of building a library for a specific set of utilities: we can figure out exactly what we need! What if we provide a method for every possible configuration of operands? In fact, we could do that based on the type, much like how parse
works in the standard library.
// arch
let _: () = operands.unpack()?;
// base32
let file: PathBuf = operands.unpack()?;
// cat
let files: &[PathBuf] = operands.unpack()?;
// cp
let (sources, dest): (&[PathBuf], PathBuf) = operands.unpack()?;
// join
let (file1, file2): (Option<PathBuf>, Option<PathBuf>) = operands.unpack()?;
However, there is a question as to how we differentiate between slices that may be empty and slices that cannot be empty. It's also a challenge to include as many possibilities as possible without having to write every single one. On the other hand, there are also not that many combinations that make sense.
Another important open question: how do we get the argument names into the error messages? Presumably, I would need to be something like this:
let (sources, dest): (&[PathBuf], PathBuf) = operands.unpack(("SOURCES", "DEST"))?;
The signature for that is gonna get ugly, but it looks kinda nice when used 😄
Method 4: Declarative Macro to the Rescue?
This could also be provided as a macro:
let (sources, dest) = unpack!("SOURCES... DEST", operands)?;
This looks even nicer, but does add a lot of additional complexity. It might be possible to make this a declarative macro with a different syntax though:
let (sources, dest) = unpack!(operands, SOURCES.., DEST);
Let's think about that last one. First, we need some types:
struct Required(&'static str);
struct Optional(&'static str);
struct ZeroOrMore(&'static str);
struct OneOrMore(&'static str);
Then:
unpack!(operands, SOURCES.., DEST)
// expands to
(OneOrMore("SOURCES"), Required("DEST")).unpack(operands)
And then we implement Unpack
in the library for all combinations we need
impl Unpack for (OneOrMore, Required) {
type Output = (&[OsString], OsString);
fn unpack(&self, operands: Vec<OsString>) -> Result<Self::Output> {
// ..
}
}