From 5746a7fa97cc61a552b8df821c93e0863eb24094 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 31 Oct 2024 09:46:32 -0500 Subject: [PATCH] fix: allow calling `usage g completion -f` Fixes #142 --- cli/src/cli/generate/completion.rs | 34 +++++---- lib/src/complete/bash.rs | 93 +++++++++++++++++++++--- lib/src/complete/fish.rs | 82 ++++++++++++++++++--- lib/src/complete/zsh.rs | 113 +++++++++++++++++++++++++---- mise.lock | 5 ++ mise.toml | 2 +- 6 files changed, 277 insertions(+), 52 deletions(-) create mode 100644 mise.lock diff --git a/cli/src/cli/generate/completion.rs b/cli/src/cli/generate/completion.rs index 13ac9a9..9061d14 100644 --- a/cli/src/cli/generate/completion.rs +++ b/cli/src/cli/generate/completion.rs @@ -1,4 +1,6 @@ use clap::Args; +use std::path::PathBuf; +use usage::Spec; #[derive(Args)] #[clap(visible_alias = "c", aliases = ["complete", "completions"])] @@ -14,31 +16,31 @@ pub struct Completion { /// Defaults to "$bin --usage" #[clap(long)] usage_cmd: Option, - // #[clap(short, long)] - // file: Option, - // + #[clap(short, long)] + file: Option, // #[clap(short, long, required_unless_present = "file", overrides_with = "file")] // spec: Option, } impl Completion { pub fn run(&self) -> miette::Result<()> { - // let spec = if let Some(file) = &self.file { - // let (spec, _) = Spec::parse_file(file)?; - // spec - // } else { - // Spec::parse_spec(self.spec.as_ref().unwrap())? - // }; + // TODO: refactor this + let (spec, _) = match &self.file { + Some(file) => Spec::parse_file(file)?, + None => (Spec::default(), "".to_string()), + }; + let spec = match self.file.is_some() { + true => Some(&spec), + false => None, + }; + let bin = &self.bin; - let usage_cmd = self - .usage_cmd - .clone() - .unwrap_or_else(|| format!("{bin} --usage")); + let usage_cmd = self.usage_cmd.as_deref(); let script = match self.shell.as_str() { - "bash" => usage::complete::bash::complete_bash(bin, &usage_cmd), - "fish" => usage::complete::fish::complete_fish(bin, &usage_cmd), - "zsh" => usage::complete::zsh::complete_zsh(bin, &usage_cmd), + "bash" => usage::complete::bash::complete_bash(bin, usage_cmd, spec), + "fish" => usage::complete::fish::complete_fish(bin, usage_cmd, spec), + "zsh" => usage::complete::zsh::complete_zsh(bin, usage_cmd, spec), _ => unreachable!(), }; println!("{}", script.trim()); diff --git a/lib/src/complete/bash.rs b/lib/src/complete/bash.rs index c51f720..853d0a5 100644 --- a/lib/src/complete/bash.rs +++ b/lib/src/complete/bash.rs @@ -1,24 +1,39 @@ +use crate::Spec; use heck::ToShoutySnakeCase; -pub fn complete_bash(bin: &str, usage_cmd: &str) -> String { - // let usage = env::USAGE_BIN.display(); +pub fn complete_bash(bin: &str, usage_cmd: Option<&str>, spec: Option<&Spec>) -> String { let bin_up = bin.to_shouty_snake_case(); - // let bin = &spec.bin; - // let raw = shell_escape::unix::escape(spec.to_string().into()); - format!( - r#" -_{bin}() {{ + let mut out = vec![format!( + r#"_{bin}() {{ if ! command -v usage &> /dev/null; then echo >&2 echo "Error: usage CLI not found. This is required for completions to work in {bin}." >&2 echo "See https://usage.jdx.dev for more information." >&2 return 1 - fi + fi"# + )]; + if let Some(usage_cmd) = &usage_cmd { + out.push(format!( + r#" if [[ -z ${{_USAGE_SPEC_{bin_up}:-}} ]]; then _USAGE_SPEC_{bin_up}="$({usage_cmd})" - fi + fi"# + )); + } + if let Some(spec) = &spec { + out.push(format!( + r#" + read -r -d '' _USAGE_SPEC_{bin_up} <<'__USAGE_EOF__' +{spec} +__USAGE_EOF__"#, + spec = spec.to_string().trim() + )); + } + + out.push(format!( + r#" COMPREPLY=( $(usage complete-word --shell bash -s "${{_USAGE_SPEC_{bin_up}}}" --cword="$COMP_CWORD" -- "${{COMP_WORDS[@]}}" ) ) if [[ $? -ne 0 ]]; then unset COMPREPLY @@ -29,17 +44,20 @@ _{bin}() {{ shopt -u hostcomplete && complete -o nospace -o bashdefault -o nosort -F _{bin} {bin} # vim: noet ci pi sts=0 sw=4 ts=4 ft=sh "# - ) + )); + + out.join("\n") } #[cfg(test)] mod tests { use super::*; + use crate::test::SPEC_KITCHEN_SINK; use insta::assert_snapshot; #[test] fn test_complete_bash() { - assert_snapshot!(complete_bash("mycli", "mycli complete --usage").trim(), @r###" + assert_snapshot!(complete_bash("mycli", Some("mycli complete --usage"), None).trim(), @r###" _mycli() { if ! command -v usage &> /dev/null; then echo >&2 @@ -62,5 +80,58 @@ mod tests { shopt -u hostcomplete && complete -o nospace -o bashdefault -o nosort -F _mycli mycli # vim: noet ci pi sts=0 sw=4 ts=4 ft=sh "###); + + assert_snapshot!(complete_bash("mycli", None, Some(&SPEC_KITCHEN_SINK)).trim(), @r##" + _mycli() { + if ! command -v usage &> /dev/null; then + echo >&2 + echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 + echo "See https://usage.jdx.dev for more information." >&2 + return 1 + fi + + read -r -d '' _USAGE_SPEC_MYCLI <<'__USAGE_EOF__' + name "mycli" + bin "mycli" + source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs" + flag "--flag1" help="flag1 description" + flag "--flag2" help="flag2 description" { + long_help "flag2 long description" + } + flag "--flag3" help="flag3 description" negate="--no-flag3" + flag "--shell" { + arg "" { + choices "bash" "zsh" "fish" + } + } + arg "" help="arg1 description" + arg "" help="arg2 description" default="default value" { + choices "choice1" "choice2" "choice3" + } + arg "" help="arg3 description" help_long="arg3 long description" + arg "..." var=true + cmd "plugin" { + cmd "install" { + flag "-g --global" + flag "-d --dir" { + arg "" + } + flag "-f --force" negate="--no-force" + arg "" + arg "" + } + } + __USAGE_EOF__ + + COMPREPLY=( $(usage complete-word --shell bash -s "${_USAGE_SPEC_MYCLI}" --cword="$COMP_CWORD" -- "${COMP_WORDS[@]}" ) ) + if [[ $? -ne 0 ]]; then + unset COMPREPLY + fi + return 0 + } + + shopt -u hostcomplete && complete -o nospace -o bashdefault -o nosort -F _mycli mycli + # vim: noet ci pi sts=0 sw=4 ts=4 ft=sh + "##); } } diff --git a/lib/src/complete/fish.rs b/lib/src/complete/fish.rs index aafafc5..4c217c8 100644 --- a/lib/src/complete/fish.rs +++ b/lib/src/complete/fish.rs @@ -1,8 +1,7 @@ -pub fn complete_fish(bin: &str, usage_cmd: &str) -> String { - // let usage = env::USAGE_BIN.display(); - // let bin = &spec.bin; - // let raw = spec.to_string().replace('\'', r"\'").to_string(); - format!( +use crate::Spec; + +pub fn complete_fish(bin: &str, usage_cmd: Option<&str>, spec: Option<&Spec>) -> String { + let mut out = vec![format!( r#" # if "usage" is not installed show an error if ! command -v usage &> /dev/null @@ -10,17 +9,35 @@ if ! command -v usage &> /dev/null echo "Error: usage CLI not found. This is required for completions to work in {bin}." >&2 echo "See https://usage.jdx.dev for more information." >&2 return 1 -end +end"# + )]; + + if let Some(usage_cmd) = &usage_cmd { + out.push(format!( + r#" +set _usage_spec_{bin} ({usage_cmd} | string collect)"# + )); + } + + if let Some(spec) = &spec { + let spec_escaped = spec.to_string().replace("'", r"\'"); + out.push(format!( + r#" +set -x _usage_spec_{bin} '{spec_escaped}'"# + )); + } + + out.push(format!( + r#"complete -xc {bin} -a '(usage complete-word --shell fish -s "$_usage_spec_{bin}" -- (commandline -cop) (commandline -t))'"# + )); -set _usage_spec_{bin} ({usage_cmd} | string collect) -complete -xc {bin} -a '(usage complete-word --shell fish -s "$_usage_spec_{bin}" -- (commandline -cop) (commandline -t))' -"# - ) + out.join("\n") } #[cfg(test)] mod tests { use super::*; + use crate::test::SPEC_KITCHEN_SINK; use insta::assert_snapshot; #[test] @@ -28,7 +45,7 @@ mod tests { // let spec = r#" // "#; // let spec = Spec::parse(&Default::default(), spec).unwrap(); - assert_snapshot!(complete_fish("mycli", "mycli complete --usage").trim(), @r###" + assert_snapshot!(complete_fish("mycli", Some("mycli complete --usage"), None).trim(), @r###" # if "usage" is not installed show an error if ! command -v usage &> /dev/null echo >&2 @@ -40,5 +57,48 @@ mod tests { set _usage_spec_mycli (mycli complete --usage | string collect) complete -xc mycli -a '(usage complete-word --shell fish -s "$_usage_spec_mycli" -- (commandline -cop) (commandline -t))' "###); + + assert_snapshot!(complete_fish("mycli", None, Some(&SPEC_KITCHEN_SINK)).trim(), @r##" + # if "usage" is not installed show an error + if ! command -v usage &> /dev/null + echo >&2 + echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 + echo "See https://usage.jdx.dev for more information." >&2 + return 1 + end + + set -x _usage_spec_mycli 'name "mycli" + bin "mycli" + source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs" + flag "--flag1" help="flag1 description" + flag "--flag2" help="flag2 description" { + long_help "flag2 long description" + } + flag "--flag3" help="flag3 description" negate="--no-flag3" + flag "--shell" { + arg "" { + choices "bash" "zsh" "fish" + } + } + arg "" help="arg1 description" + arg "" help="arg2 description" default="default value" { + choices "choice1" "choice2" "choice3" + } + arg "" help="arg3 description" help_long="arg3 long description" + arg "..." var=true + cmd "plugin" { + cmd "install" { + flag "-g --global" + flag "-d --dir" { + arg "" + } + flag "-f --force" negate="--no-force" + arg "" + arg "" + } + } + ' + complete -xc mycli -a '(usage complete-word --shell fish -s "$_usage_spec_mycli" -- (commandline -cop) (commandline -t))' + "##); } } diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index bbc542c..4ad6946 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -1,14 +1,18 @@ // use crate::env; -pub fn complete_zsh(bin: &str, usage_cmd: &str) -> String { - // let usage = env::USAGE_BIN.display(); - // let cmds = vec![&spec.cmd]; - // let args = render_args(&cmds); - format!( +use crate::Spec; + +pub fn complete_zsh(bin: &str, usage_cmd: Option<&str>, spec: Option<&Spec>) -> String { + // let bin_snake = bin.to_snake_case(); + let mut out = vec![format!( r#" #compdef {bin} -local curcontext="$curcontext" +local curcontext="$curcontext""# + )]; + if let Some(_usage_cmd) = &usage_cmd { + out.push(format!( + r#" # caching config _usage_{bin}_cache_policy() {{ if [[ -z "${{lifetime}}" ]]; then @@ -17,8 +21,12 @@ _usage_{bin}_cache_policy() {{ local -a oldp oldp=( "$1"(Nms+${{lifetime}}) ) (( $#oldp )) -}} +}}"# + )); + } + out.push(format!( + r#" _{bin}() {{ typeset -A opt_args local curcontext="$curcontext" spec cache_policy @@ -28,8 +36,12 @@ _{bin}() {{ echo "Error: usage CLI not found. This is required for completions to work in {bin}." >&2 echo "See https://usage.jdx.dev for more information." >&2 return 1 - fi + fi"#, + )); + if let Some(usage_cmd) = &usage_cmd { + out.push(format!( + r#" zstyle -s ":completion:${{curcontext}}:" cache-policy cache_policy if [[ -z $cache_policy ]]; then zstyle ":completion:${{curcontext}}:" cache-policy _usage_{bin}_cache_policy @@ -40,8 +52,21 @@ _{bin}() {{ then spec="$({usage_cmd})" _store_cache _usage_{bin}_spec spec - fi + fi"# + )); + } + + if let Some(spec) = &spec { + out.push(format!( + r#"read -r -d '' spec <<'__USAGE_EOF__' +{spec} +__USAGE_EOF__"#, + spec = spec.to_string().trim() + )); + } + out.push(format!( + r#" _arguments "*: :(($(usage complete-word --shell zsh -s "$spec" -- "${{words[@]}}" )))" return 0 }} @@ -52,9 +77,10 @@ else compdef _{bin} {bin} fi -# vim: noet ci pi sts=0 sw=4 ts=4 -"# - ) +# vim: noet ci pi sts=0 sw=4 ts=4"#, + )); + + out.join("\n") } // fn render_args(cmds: &[&SchemaCmd]) -> String { @@ -64,6 +90,7 @@ fi #[cfg(test)] mod tests { use super::*; + use crate::test::SPEC_KITCHEN_SINK; use insta::assert_snapshot; #[test] @@ -71,7 +98,7 @@ mod tests { // let spec = r#" // "#; // let spec = Spec::parse(&Default::default(), spec).unwrap(); - assert_snapshot!(complete_zsh("mycli", "mycli complete --usage").trim(), @r###" + assert_snapshot!(complete_zsh("mycli", Some("mycli complete --usage"), None).trim(), @r###" #compdef mycli local curcontext="$curcontext" @@ -120,5 +147,65 @@ mod tests { # vim: noet ci pi sts=0 sw=4 ts=4 "###); + assert_snapshot!(complete_zsh("mycli", None, Some(&SPEC_KITCHEN_SINK)), @r##" + + #compdef mycli + local curcontext="$curcontext" + + _mycli() { + typeset -A opt_args + local curcontext="$curcontext" spec cache_policy + + if ! command -v usage &> /dev/null; then + echo >&2 + echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 + echo "See https://usage.jdx.dev for more information." >&2 + return 1 + fi + read -r -d '' spec <<'__USAGE_EOF__' + name "mycli" + bin "mycli" + source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs" + flag "--flag1" help="flag1 description" + flag "--flag2" help="flag2 description" { + long_help "flag2 long description" + } + flag "--flag3" help="flag3 description" negate="--no-flag3" + flag "--shell" { + arg "" { + choices "bash" "zsh" "fish" + } + } + arg "" help="arg1 description" + arg "" help="arg2 description" default="default value" { + choices "choice1" "choice2" "choice3" + } + arg "" help="arg3 description" help_long="arg3 long description" + arg "..." var=true + cmd "plugin" { + cmd "install" { + flag "-g --global" + flag "-d --dir" { + arg "" + } + flag "-f --force" negate="--no-force" + arg "" + arg "" + } + } + __USAGE_EOF__ + + _arguments "*: :(($(usage complete-word --shell zsh -s "$spec" -- "${words[@]}" )))" + return 0 + } + + if [ "$funcstack[1]" = "_mycli" ]; then + _mycli "$@" + else + compdef _mycli mycli + fi + + # vim: noet ci pi sts=0 sw=4 ts=4 + "##); } } diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..03ad111 --- /dev/null +++ b/mise.lock @@ -0,0 +1,5 @@ +[tools] +"cargo:cargo-edit" = "0.13.0" +"cargo:git-cliff" = "2.6.1" +gh = "2.60.1" +"npm:prettier" = "3.3.3" diff --git a/mise.toml b/mise.toml index 1468895..41bcb33 100644 --- a/mise.toml +++ b/mise.toml @@ -6,7 +6,7 @@ _.path = ["./target/debug"] "npm:prettier" = "latest" "cargo:cargo-edit" = "latest" "cargo:git-cliff" = "latest" -github-cli = "latest" +gh = "latest" [tasks.build] sources = ['{cli/,}src/**/*.rs', '{cli/,}Cargo.toml']