From 80979f0ceaaec72e90067ed70c325b5abe4b555f Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Thu, 7 Dec 2023 15:31:32 +0100 Subject: [PATCH] initial implementation of completion for fish --- .github/workflows/ci.yml | 2 +- Cargo.toml | 9 ++++-- complete/Cargo.toml | 10 ++++++ complete/LICENSE | 1 + complete/src/fish.rs | 67 ++++++++++++++++++++++++++++++++++++++++ complete/src/lib.rs | 40 ++++++++++++++++++++++++ derive/src/complete.rs | 45 +++++++++++++++++++++++++++ derive/src/lib.rs | 9 +++++- examples/hello_world.rs | 8 ++--- src/lib.rs | 64 +++++++++++++++++++++++++++++++------- src/value.rs | 17 ++++++++++ 11 files changed, 250 insertions(+), 22 deletions(-) create mode 100644 complete/Cargo.toml create mode 120000 complete/LICENSE create mode 100644 complete/src/fish.rs create mode 100644 complete/src/lib.rs create mode 100644 derive/src/complete.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3039939..c03e0c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions-rs/cargo@v1 with: command: test - args: --all-features --workspace + args: --features complete --workspace rustfmt: name: Rustfmt diff --git a/Cargo.toml b/Cargo.toml index 415b33a..138b034 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,13 @@ readme = "README.md" [dependencies] uutils-args-derive = { version = "0.1.0", path = "derive" } +uutils-args-complete = { version = "0.1.0", path = "complete", optional = true } strsim = "0.10.0" lexopt = "0.3.0" +[features] +parse-is-complete = ["complete"] +complete = ["uutils-args-complete"] + [workspace] -members = [ - "derive", -] +members = ["derive", "complete"] diff --git a/complete/Cargo.toml b/complete/Cargo.toml new file mode 100644 index 0000000..54abe1a --- /dev/null +++ b/complete/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "uutils-args-complete" +version = "0.1.0" +edition = "2021" +authors = ["Terts Diepraam"] +license = "MIT" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/complete/LICENSE b/complete/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/complete/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/complete/src/fish.rs b/complete/src/fish.rs new file mode 100644 index 0000000..5fcb3cd --- /dev/null +++ b/complete/src/fish.rs @@ -0,0 +1,67 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use crate::{Command, ValueHint}; + +pub fn render(c: &Command) -> String { + let mut out = String::new(); + let name = &c.name; + for arg in &c.args { + let mut line = format!("complete -c {name}"); + for short in &arg.short { + line.push_str(&format!(" -s {short}")); + } + for long in &arg.long { + line.push_str(&format!(" -l {long}")); + } + line.push_str(&format!(" -d '{}'", arg.help)); + if let Some(value) = &arg.value { + line.push_str(&render_value_hint(value)); + } + out.push_str(&line); + out.push('\n'); + } + out +} + +fn render_value_hint(value: &ValueHint) -> String { + match value { + ValueHint::Strings(s) => { + let joined = s.join(", "); + format!(" -a {{ {joined} }}") + } + _ => todo!(), + } +} + +#[cfg(test)] +mod test { + use super::render; + use crate::{Arg, Command}; + + #[test] + fn short() { + let c = Command { + name: "test".into(), + args: vec![Arg { + short: vec!["a".into()], + help: "some flag".into(), + ..Arg::default() + }], + }; + assert_eq!(render(&c), "complete -c test -s a -d 'some flag'\n",) + } + + #[test] + fn long() { + let c = Command { + name: "test".into(), + args: vec![Arg { + long: vec!["all".into()], + help: "some flag".into(), + ..Arg::default() + }], + }; + assert_eq!(render(&c), "complete -c test -l all -d 'some flag'\n",) + } +} diff --git a/complete/src/lib.rs b/complete/src/lib.rs new file mode 100644 index 0000000..32a88fb --- /dev/null +++ b/complete/src/lib.rs @@ -0,0 +1,40 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +mod fish; + +pub struct Command { + pub name: String, + pub args: Vec, +} + +#[derive(Default)] +pub struct Arg { + pub short: Vec, + pub long: Vec, + pub help: String, + pub value: Option, +} + +pub enum ValueHint { + Strings(Vec), + Unknown, + // Other, + AnyPath, + // FilePath, + // DirPath, + // ExecutablePath, + // CommandName, + // CommandString, + // CommandWithArguments, + // Username, + // Hostname, +} + +pub fn render(c: &Command, shell: &str) -> String { + match shell { + "fish" => fish::render(c), + "sh" | "zsh" | "bash" | "csh" => panic!("shell '{shell}' completion is not supported yet!"), + _ => panic!("unknown shell '{shell}'!"), + } +} diff --git a/derive/src/complete.rs b/derive/src/complete.rs new file mode 100644 index 0000000..daa30a0 --- /dev/null +++ b/derive/src/complete.rs @@ -0,0 +1,45 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use crate::{ + argument::{ArgType, Argument}, + flags::{Flags, Flag}, +}; +use proc_macro2::TokenStream; +use quote::quote; + +pub fn complete(args: &[Argument]) -> TokenStream { + let mut arg_specs = Vec::new(); + + for Argument { help, arg_type, .. } in args { + let ArgType::Option { + flags, + hidden: false, + .. + } = arg_type + else { + continue; + }; + + let Flags { short, long, .. } = flags; + if short.is_empty() && long.is_empty() { + continue; + } + let short: Vec<_> = short.iter().map(|Flag { flag, .. }| quote!(String::from(#flag))).collect(); + let long: Vec<_> = long.iter().map(|Flag { flag, .. }| quote!(String::from(#flag))).collect(); + + arg_specs.push(quote!( + Arg { + short: vec![#(#short),*], + long: vec![#(#long),*], + help: String::from(#help), + value: None, + } + )) + } + + quote!(Command { + name: String::from(option_env!("CARGO_BIN_NAME").unwrap_or(env!("CARGO_PKG_NAME"))), + args: vec![#(#arg_specs),*] + }) +} diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 7383602..512e732 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -7,6 +7,7 @@ mod flags; mod help; mod help_parser; mod initial; +mod complete; use argument::{ free_handling, long_handling, parse_argument, parse_arguments_attr, positional_handling, @@ -50,6 +51,7 @@ pub fn arguments(input: TokenStream) -> TokenStream { &arguments_attr.version_flags, &arguments_attr.file, ); + let completion = complete::complete(&arguments); let help = help_handling(&arguments_attr.help_flags); let version = version_handling(&arguments_attr.version_flags); let version_string = quote!(format!( @@ -80,7 +82,6 @@ pub fn arguments(input: TokenStream) -> TokenStream { ) -> Result>, uutils_args::Error> { use uutils_args::{Value, lexopt, Error, Argument}; - // #number_argment #free let arg = match { #next_arg } { @@ -110,6 +111,12 @@ pub fn arguments(input: TokenStream) -> TokenStream { fn version() -> String { #version_string } + + #[cfg(feature = "complete")] + fn complete() -> ::uutils_args_complete::Command { + use ::uutils_args_complete::{Command, Arg, ValueHint}; + #completion + } } ); diff --git a/examples/hello_world.rs b/examples/hello_world.rs index 5d95993..d7a4261 100644 --- a/examples/hello_world.rs +++ b/examples/hello_world.rs @@ -3,15 +3,11 @@ use uutils_args::{Arguments, Initial, Options}; #[derive(Arguments)] #[arguments(file = "examples/hello_world_help.md")] enum Arg { - /// The *name* to **greet** - /// - /// Just to show off, I can do multiple paragraphs and wrap text! - /// - /// # Also headings! + /// The name to greet #[arg("-n NAME", "--name=NAME", "name=NAME")] Name(String), - /// The **number of times** to `greet` + /// The number of times to greet #[arg("-c N", "--count=N")] Count(u8), diff --git a/src/lib.rs b/src/lib.rs index 8f15b8b..8d59372 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,8 +14,8 @@ pub use value::{Value, ValueError, ValueResult}; use std::{ ffi::{OsStr, OsString}, - marker::PhantomData, io::Write, + marker::PhantomData, }; /// A wrapper around a type implementing [`Arguments`] that adds `Help` @@ -108,6 +108,9 @@ pub trait Arguments: Sized { while iter.next_arg()?.is_some() {} Ok(()) } + + #[cfg(feature = "complete")] + fn complete() -> uutils_args_complete::Command; } /// An iterator over arguments. @@ -207,13 +210,31 @@ pub trait Options: Sized + Initial { I: IntoIterator + 'static, I::Item: Into, { - let mut _self = Self::initial(); - let mut iter = Arg::parse(args); - while let Some(arg) = iter.next_arg()? { - _self.apply(arg); + // Hacky but it works: if the parse-is-complete flag + // is active the parse function becomes the complete + // function so that no additional functionality is + // necessary for users to generate completions. + #[cfg(feature = "parse-is-complete")] + { + print_completion::<_, Self, Arg>(args.into_iter()); + std::process::exit(0); + } + + #[cfg(not(feature = "parse-is-complete"))] + { + let mut _self = Self::initial(); + let mut iter = Arg::parse(args); + while let Some(arg) = iter.next_arg()? { + _self.apply(arg); + } + Arg::check_missing(iter.positional_idx)?; + Ok(_self) } - Arg::check_missing(iter.positional_idx)?; - Ok(_self) + } + + #[cfg(feature = "complete")] + fn complete(shell: &str) -> String { + uutils_args_complete::render(&Arg::complete(), shell) } } @@ -240,6 +261,22 @@ pub fn __echo_style_positional(p: &mut lexopt::Parser, short_args: &[char]) -> O } } +#[cfg(feature = "complete")] +fn print_completion, Arg: Arguments>(mut args: I) +where + I: Iterator + 'static, + I::Item: Into, +{ + let _exec_name = args.next(); + let shell = args + .next() + .expect("Need a shell argument for completion.") + .into(); + let shell = shell.to_string_lossy(); + assert!(args.next().is_none(), "completion only takes one argument"); + println!("{}", O::complete(&shell)); +} + fn is_echo_style_positional(s: &OsStr, short_args: &[char]) -> bool { let s = match s.to_str() { Some(x) => x, @@ -317,7 +354,12 @@ pub fn filter_suggestions(input: &str, long_options: &[&str], prefix: &str) -> V .collect() } -pub fn print_flags(mut w: impl Write, indent_size: usize, width: usize, options: impl IntoIterator) -> std::io::Result<()> { +pub fn print_flags( + mut w: impl Write, + indent_size: usize, + width: usize, + options: impl IntoIterator, +) -> std::io::Result<()> { let indent = " ".repeat(indent_size); writeln!(w, "\nOptions:")?; for (flags, help_string) in options { @@ -330,15 +372,15 @@ pub fn print_flags(mut w: impl Write, indent_size: usize, width: usize, options: None => { writeln!(w)?; continue; - }, + } }; - let help_indent = " ".repeat(width-flags.len()+2); + let help_indent = " ".repeat(width - flags.len() + 2); writeln!(w, "{}{}", help_indent, line)?; } else { writeln!(w)?; } - let help_indent = " ".repeat(width+indent_size+2); + let help_indent = " ".repeat(width + indent_size + 2); for line in help_lines { writeln!(w, "{}{}", help_indent, line)?; } diff --git a/src/value.rs b/src/value.rs index e95b9a4..ae9e3f7 100644 --- a/src/value.rs +++ b/src/value.rs @@ -6,6 +6,8 @@ use std::{ ffi::{OsStr, OsString}, path::PathBuf, }; +#[cfg(feature = "complete")] +use uutils_args_complete::ValueHint; pub type ValueResult = Result>; @@ -51,6 +53,11 @@ impl std::fmt::Display for ValueError { /// If an error is returned, it will be wrapped in [`Error::ParsingFailed`] pub trait Value: Sized { fn from_value(value: &OsStr) -> ValueResult; + + #[cfg(feature = "complete")] + fn value_hint() -> ValueHint { + ValueHint::Unknown + } } impl Value for OsString { @@ -63,6 +70,11 @@ impl Value for PathBuf { fn from_value(value: &OsStr) -> ValueResult { Ok(PathBuf::from(value)) } + + #[cfg(feature = "complete")] + fn value_hint() -> ValueHint { + ValueHint::AnyPath + } } impl Value for String { @@ -81,6 +93,11 @@ where fn from_value(value: &OsStr) -> ValueResult { Ok(Some(T::from_value(value)?)) } + + #[cfg(feature = "complete")] + fn value_hint() -> uutils_args_complete::ValueHint { + T::value_hint() + } } macro_rules! value_int {