-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(commands)!: add support for flags #12288
base: master
Are you sure you want to change the base?
Conversation
refactor: no longer special case for bracket lists refactor: no longer special case end space This was a hold over from before the `raw` function was added to `Args`. perf: remove `bytes` field to save 16 bytes From 56 bytes to 40, saving 16 bytes. perf: move `in_quotes` field to local variable perf: move `quote` field to local variable refactor: remove `is_finished` state from `Args` test: change example command to `read` `yank-join` now uses `raw` and thus would not be parsed with the `next` function so no longer applicable. refactor: remove unneeded range end for index refactor: remove backtracking escape check Instead, it can be tracked as the parser scans through the first time. refactor: clean up code and add more comments feat(shellwords): add test for empty values
refactor(commands): ignore unit pattern match refactor(commands): use `if let` over `matches!` refactor(commands): ignore unit pattern match perf(commands):remove unnecessary ref for a &str refactor(commands): change `MappableCommand` `args` from `Vec<String>` to String refactor(commands): use `Args::raw` over `fold`ing refactor: use `rest` in `set_option` command refactor: use `rest` in `toggle_option` command chore(dap): add TODOs to switch to `Args` perf: change `ok_or` to `ok_or_else` to make fn call lazy feat(shellwords): add `args_count` to `Args` fix(commands): default to `line_ending` for `yank_join` when args are empty
@the-mikedavis If this looks promising, then I would be willing to see it to merger. If not, then I can wait to get some more feedback before I would think of fully picking this up. This diff includes changes from the dependent tree. For this pr, there is the The |
One friction point I found was that let flags = flags
.iter()
.map(|flag| flag.long)
.chain(flags.iter().filter_map(|flag| flag.short)); as edit: Added a trait |
The prompt logic was changed to deal with indicating optional flags and potentially to indicate if a command takes arguments or not. let shellwords = Shellwords::from(input);
if let Some(typed::TypableCommand {
name,
aliases,
flags,
doc,
..
}) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command())
{
// EXAMPLE:
// write [<flags>] - write the current buffer to its file.
// aliases: w
// flags:
// --no-format exclude formatting operation when saving.
let mut prompt = String::new();
prompt.push_str(name);
if !flags.is_empty() {
prompt.push_str(" [<flags>]");
}
writeln!(prompt, " - {doc}").unwrap();
if !aliases.is_empty() {
writeln!(prompt, "aliases: {}", aliases.join(", ")).unwrap();
}
if !flags.is_empty() {
prompt.push_str("flags:\n");
for flag in *flags {
write!(prompt, " --{}", flag.long).unwrap();
if let Some(short) = flag.short {
write!(prompt, ", -{short}").unwrap();
}
// TODO: Need to add if the flag takes any arguments?
// --all, -a <arg> will save all
writeln!(prompt, " {}", flag.desc).unwrap();
}
}
return Some(prompt.into());
}
None Currently it does not show if the command itself takes arguments: write [<flags>] - write the current buffer to its file.
aliases: w
flags:
--no-format exclude formatting operation when saving. But there could be something like this added: write [<flags>] ... - write the current buffer to its file.
^ ?
aliases: w
flags:
--no-format ... exclude formatting operation when saving. Where then the docs can explain further. Another option would be to add something like a enum Takes {
None,
One(&str),
Many(&str),
} Where there could be something like This would entail adding to both the |
Should also mention up front that no new dependencies where brought in for this. Its unknown if this remains the case going forward. |
As brought up here, let flags = flags
.iter()
.map(|flag| flag.long)
.chain(flags.iter().filter_map(|flag| flag.short));
let mut encountered = false;
if let Some(flag) = args.flags(flags) {
match flag {
"reverse" | "r" if !encountered => {
encountered = true;
sort_impl(cx, true)
}
_ => {
bail!("unhandled command flag `{flag}`, implementation failed to cover all flags.")
}
}
} else {
sort_impl(cx, false);
} The issue of trying to handle it so that all flags can only be given once is that not all commands might want to adhere to this restriction. If
|
One other limitation is that to keep iterating over the potential flags, the iterator must be cloned, as each iteration all flags must be checked for. This is the main mechanism that allows random order flags. But considering this is dealing with while let Some(flag) = args.flags(flags.clone()) {} edit: if let Some(flag) = args.flag(&flags) {
match flag {
"reverse" | "r" => sort_impl(cx, true),
_ => {
bail!("unhandled command flag `{flag}`, implementation failed to cover all flags.")
}
}
} else {
sort_impl(cx, false);
} |
Changed flags: &[flag!{...}] to flags: flags![] And can be provided with flags using flags: flags![
{
long: "reverse", // required
short: "r", // optional
desc: "sort ranges in selection in reverse order" // required
}
], |
The changes look pretty good to me so far. I think we will try to get #11149 merged right after the next release (so it has time to be thoroughly tested in master) and then we can hopefully take a proper look at this within the same release cycle. The code for flags will probably take some design/discussion. #[derive(Default)]
struct SortFlags {
reverse: bool,
} and maybe within the implementation of let (flags, args) = args.split_flags::<SortFlags>()?; // returns an Err for unknown flags
let sort_reverse = flags.reverse; Most typable commands would have a sort of no-op type for this ( command_flags! {
SortFlags;
reverse: bool, "r", "sort ranges in selection in reverse order";
} (or something, that's just rough pseudocode) And the declarative macro would have enough information to implement a few trait functions that could list the flags, shorthands and descriptions, and |
Sounds good to me. I know there are still a few things for that pr that are still unclear for me, as far as expected parsing rules, so testing is a definite must. As the for the requirements here, I had also thought a typed flags approach was something worth discussing. I agree that the boilerplate currently leaves some to be desired, but wasn't sure how to cover potentially needed bespoke use cases. For example, here: let (flags, args) = args.split_flags::<SortFlags>()?; // returns an Err for unknown flags
let sort_reverse = flags.reverse; In this alone, we have an To solve these, perhaps there could be a let flags = args.try_extract_flags::<SortFlags>()?; // Doesn't return error on unknown flags, but tries to populate all known flags.
if flags.reverse { ... } In this case though, if you want to pass in an argument that has the same name as a flag then you would need to escape it with something not The error returned would then be if the parse function fails if, for example, a flag is expected to itself take an input, and there was none provided. Otherwise, it just returns the default implimentation for the fields. Doing it this way, though, could bring us back to the unknown flags issue. As for implimentation details, I can imagine something like: // impl Args
fn try_extract_flags<F: Flag>(&self) -> anyhow::Result<F> {
let mut flag = F::default();
let mut args = self.clone();
while let Some(arg) = args.next() {
flag.extract(arg, &mut args)?;
}
Ok(flag)
} With the trait something like: pub trait Flag: Default {
fn extract<'a, Args: Iterator<Item = &'a str>>(
&mut self,
arg: &'a str,
mut args: &mut Args,
) -> anyhow::Result<()> {
if arg == "--" {
return Ok(());
}
match arg.trim_start_matches("--").trim_start_matches('-') {
// Would match the field name: join
"join" => self.NAME = true,
// Optional short
"register" | "r" => {
self.NAME = args
.next()
// In the Flag implementing struct, could be an `accepts: Option<&'static str>` so that context can
// be provided?
.context("`NAME` was not not provided a `ACCEPTS`")?
}
_ => {}
}
Ok(())
}
} If the flag takes multiple arguments then would need to handle that as well. Would also decide if As for the prompt, this generative change would significantly increase the complexity for its generation, but its probably something that would be best done regardless. For some smaller refactors, making a And lastly, for the macros approach as a whole, contributing to changes and general maintenance in this part of the code would become much more complex. I also think there could be some future large changes to the commands themselves to open up specific use cases, with this potentially including generating commands itself, so wonder how much we want to take into account these potential changes now, with this implimentation; keeping a simpler approach or not. |
Before I forget, another middle ground approach, if we moved the checks to the pub fn try_extract_flags<F: Flag>(&mut self) -> anyhow::Result<F> {
let mut flag = F::default();
while let Some(arg) = self.next() {
if arg == "--" {
return Ok(flag);
}
let arg = arg.trim_start_matches("--").trim_start_matches('-');
flag.extract(arg, self)?;
}
Ok(flag)
} This would only support flag-first-only syntax, but would advance With the trait now as: pub trait Flag: Default {
fn extract<'a, Args: Iterator<Item = &'a str>>(
&mut self,
arg: &'a str,
args: &mut Args,
) -> anyhow::Result<()>;
fn prompt(&self) -> Option<&'static str> { None }
} Then when you implement it you would match on the flags like now, but offers a bit more support for bespoke handling. The prompt part could be implemented here as well, with a convention to follow, but no hard constraints being done for the implementer. It puts more onus on new flag implementors, but id say in one of the most straightforward ways; they can decide if In the prompt building phase it would just be a: if let Some(flags) = command.flags.prompt() { ... } With the prompt building code moved to It all comes back to what the dev experience should be like for both implementing, as well as maintaining, the code. |
Starting to look into what the implimentation would be like. To start with, needing to store each type on If the TypableCommand {
name: "read",
aliases: &["r"],
flags: flags!{
// ...
},
accepts: Some("<path>"),
doc: "load a file into buffer",
fun: read,
signature: CommandSignature::positional(&[completers::filename]),
}, I don't know if the struct defined here would be accessible outside of where it was defined in the macro. We also cant just do a I'm sure there are some other things here, but even if we can get them to work, this is still a Box for each command. Don't really like that if at all possible. On the other side of this, using concrete types with generics seems like a non-starter as we have a slice and a hashmap of Perhaps I'm just not familiar enough with Rust to know the obvious solution, but it seems to me like without a major refactor around |
Well, I guess before all that, the |
Pursuing the current implimentation a bit more, as the other way is getting out of hand with "magic", I thought about seeing how I could reduce the boilerplate instead. Now, along with being able to define the flags right next to the command, // Flags
let mut join = false;
let mut register = String::new();
flags! {
for flags, args => {
"join" | "j" => join = true,
"register" | "r" => {
if let Some(reg) = args.next() {
register.push_str(reg);
} else {
// Handle if no args were provided to flag
}
}
}
} This is something that feels much better than before. The state is still free floating, but its all local still. Building off of what I feel is a strong suit of the current impl, all while being compact: #[macro_export]
macro_rules! flags {
// Empty case
[] => {
$crate::commands::flag::Flags::empty()
};
// Multiple flags case
[$({ long: $long:expr, $(short: $short:expr,)? desc: $desc:expr $(, accepts: $accepts:expr)? $(,)?}),* $(,)?] => {
{
const FLAGS: &[$crate::commands::flag::Flag] = &[
$(
$crate::commands::flag::Flag {
long: $long,
short: {
#[allow(unused_mut, unused_assignments)]
let mut short: Option<&'static str> = None;
$(short = Some($short);)?
short
},
desc: $desc,
accepts: {
#[allow(unused_mut, unused_assignments)]
let mut accepts: Option<&'static str> = None;
$(accepts = Some($accepts);)?
accepts
},
}
),*
];
$crate::commands::flag::Flags::new(FLAGS)
}
};
// Extract flags boilerplate
(for $flags:expr, $args:expr => { $($flag:pat => $action:expr),* $(,)? }) => {
while let Some(flag) = $args.flag(&$flags) {
match flag {
$(
$flag => {
$action; }
)*
_ => {
anyhow::bail!("unhandled command flag `{}`, implementation failed to cover all flags.", flag);
}
}
}
};
} This is in contrast to the monstrosity that was developing with the parsing impl. |
284eb1c
to
8e83de8
Compare
Allows peeking the next argument without consuming the iterator.
This indicates if the flag takes an argument
The `--` gets consumed, with the remaining iterator to yield only command aruguments.
Also lowercases all doc text.
Just clone instead
No longer clears if args are empty. Either a register or flag must be provided.
Moved the prompt building to it.
This allows seperate commands to be collapsed into a single one and then have their aliases to map to flag operations.
TODO:
How would we go about documentation to make sure that the flags are discoverable?
Current look using the same methodology as before:
Should prompt completions be added for the flags? If so, how would we handle competing completions?
Adds the ability to parse out flags for typeable commands.
The implimentation tries to be as straight forward as possible, using no new dependencies, with only one helper method on
Args
,flag
, to assist some boilerplate, and a trait inshellwords.rs
.flag
takes&mut self, flags: impl IntoFlags
and returns anOption<&str>
. This is done with the expected use of:command [<flags>] [<args>]
. This advances theArgs
iterator along if matches are found, so that once all flags are gotten, only arguments remain.NOTE:
Currently only known flags would be returned, so any flag not associated would get treated like an arg. This might lead to confusion on a flag typo, but nothing is coming to mind to better handle this in a generic way where many commands will be using this.
Manual Flag Termination
Flags can be terminated with a
--
to indicate to the checker that all flags that are expected to be yielded have been. Once--
is encountered,flag
returnsNone
. The--
itself is eaten in this process..Dev Experience
Basic
A basic example of adding flags to a command first involves adding the names and description. This can be done using the
flags!
macro:These can be chained for as many flags as needed.
long
anddesc
are required, with an optionalshort
for shorthand aliasing, and anaccepts
, used to indicate if a flag accepts an argument.long
flags are passed in with--LONG
. If noshort
is provided, then the only calling convention available would be to use thelong
name.short
can be called with a single dash:-SHORT
.flags!
supports empty arguments too:flags![]
, indicating no flags.This alone automatically provides formatted docs:
From there, its just a matter of checking for the flags in the callback function body, and checking which ones were matched.
flags!
provides a pattern to reduce boilerplate:All callback functions inject any flags that were declared on the command. Passing to
Args::flag
checks each argument that would be yielded via anext
, and if there is a match, it returns the flag name, stripped of any dashes, and advances the iterator. If no matches are found, then the iterator is not advanced andNone
is returned. This convention supports bothif let Some
andwhile let Some
.flag
only ever returnsSome(flag)
if a match was found, so the catch all message is there to indicate unhandled flag implementations. This should only ever happen if flags were provided on the command, but there is no branch covering its implimentation.Flag with parameters
For a more complex example, with flags that take arguments, here is a
yank
command, which incorporates other yank command functionality into one command, using the flags to dictate the behavior:Prompt
Added an
accepts: Option<&'static str>
field toTypeableCommand
and toFlags
.This exposes more information in the prompt:
This is dynamically added, and skipped when
None
.Aliasing
Added
Aliases
andAlias
structs, as well as analiases!
macro. These allow the ability to define aliases in a way that they can map to flags and all point to the same command.This means that currently separate commands, where behavior can be defined with flags, can be collapsed into one.
Dev Experience
Adding aliases is done through the
aliases!
macro like so:The pattern can be a standalone string literal, implicitly mapping to the base functionality of the command, and a mapping pattern, which maps the alias to the base command plus some combination of flags.
Prompt
Any added aliases are automatically added to the prompt:
Commands
The expectation is that this can be merged with only the few commands changed in this PR, and any other commands can be done in follow-up PRs, using the implementations done here as reference.
Breaking!
rsort
is removed, use:sort --reverse
or:sort -r
insteadclear-register
no longer clears all registers if no arguments are provided. For the same effect use:clear-register --all
or:clear-register -a
instead. This change also means that an argument of some kind MUST be provided.write-*
: all write related commands have been collapsed into a singlewrite
command.Depends: #11149
Closes: #5828