From 8103a29ac0d1cafab615648637960e03d032c9cd Mon Sep 17 00:00:00 2001 From: Cyborus <cyborus@cyborus.xyz> Date: Thu, 6 Feb 2025 21:02:45 -0500 Subject: [PATCH] feat: shell completions --- Cargo.lock | 36 +++++---- Cargo.toml | 1 + src/completion.rs | 183 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 + 4 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 src/completion.rs diff --git a/Cargo.lock b/Cargo.lock index 08e1d9c..25324a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -233,9 +233,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.15" +version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" dependencies = [ "clap_builder", "clap_derive", @@ -243,9 +243,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.15" +version = "4.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" dependencies = [ "anstream", "anstyle", @@ -255,10 +255,19 @@ dependencies = [ ] [[package]] -name = "clap_derive" -version = "4.5.13" +name = "clap_complete" +version = "4.5.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +checksum = "375f9d8255adeeedd51053574fd8d4ba875ea5fa558e86617b07f09f1680c8b6" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" dependencies = [ "heck", "proc-macro2", @@ -268,9 +277,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" @@ -605,6 +614,7 @@ dependencies = [ "base64ct", "cfg-if", "clap", + "clap_complete", "comrak", "crossterm", "directories", @@ -1864,12 +1874,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ "rustix", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 15532e3..b68460d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ auth-git2 = "0.5.4" base64ct = { version = "1.6.0", features = ["std"] } cfg-if = "1.0.0" clap = { version = "4.5.11", features = ["derive"] } +clap_complete = "4.5.44" comrak = "0.26.0" crossterm = "0.27.0" directories = "5.0.1" diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..f096413 --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,183 @@ +use std::io::Write; + +use clap::{ArgAction, Args, CommandFactory, ValueEnum}; +use eyre::OptionExt; + +#[derive(Args, Clone, Debug)] +pub struct CompletionCommand { + shell: Shell, + #[clap(long)] + bin_name: Option<String>, +} + +#[derive(ValueEnum, Clone, Debug)] +pub enum Shell { + Bash, + Elvish, + Fish, + PowerShell, + Zsh, + Nushell, +} + +impl CompletionCommand { + pub fn run(self) { + use clap_complete::Shell as CCShell; + use Shell::*; + let mut cmd = crate::App::command(); + let app_name = self.bin_name.as_deref().unwrap_or("fj"); + let mut writer = std::io::stdout(); + match self.shell { + Bash => clap_complete::generate(CCShell::Bash, &mut cmd, app_name, &mut writer), + Elvish => clap_complete::generate(CCShell::Elvish, &mut cmd, app_name, &mut writer), + Fish => clap_complete::generate(CCShell::Fish, &mut cmd, app_name, &mut writer), + PowerShell => clap_complete::generate(CCShell::PowerShell, &mut cmd, app_name, &mut writer), + Zsh => clap_complete::generate(CCShell::Zsh, &mut cmd, app_name, &mut writer), + Nushell => clap_complete::generate(NushellCompletion, &mut cmd, app_name, &mut writer), + } + } +} + +// Heavily inspired by clap_complete_nushell +// but rewritten/modified since I'm not a fan of its completions + +struct NushellCompletion; + +impl clap_complete::Generator for NushellCompletion { + fn file_name(&self, name: &str) -> String { + format!("{name}.nu") + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn Write) { + generate_command(cmd, buf).expect("failed to generate nushell completions"); + } +} + +fn generate_command(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> { + writeln!(buf, "module completions {{")?; + generate_subcommand(cmd, buf)?; + writeln!(buf, "}}")?; + writeln!(buf, "export use completions *")?; + Ok(()) +} + +fn generate_subcommand(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> { + let name = cmd.get_bin_name().ok_or_eyre("no bin name")?; + writeln!(buf, " export extern \"{name}\" [")?; + let mut args = cmd.get_arguments().collect::<Vec<_>>(); + args.sort_by_key(|arg| arg.is_positional()); + + // positional arguments + for arg in cmd.get_arguments() { + if !arg.is_positional() { + continue; + } + + write!(buf, " ")?; + let id = arg.get_id().as_str(); + + if matches!(arg.get_action(), ArgAction::Append) { + write!(buf, "...{id}")?; + } else { + write!(buf, "{id}")?; + if !arg.is_required_set() { + write!(buf, "?")?; + } + } + + arg_type(name, arg, buf)?; + writeln!(buf)?; + } + + // subcommand completion + if cmd.get_subcommands().next().is_some() { // basically `!is_empty` + writeln!(buf, " rest?: string@\"complete-subcommand {name}\",")?; + } + + // flag arguments + for arg in cmd.get_arguments() { + match (arg.get_long(), arg.get_short()) { + (Some(long), Some(short)) => write!(buf, " --{long}(-{short})")?, + (Some(long), None) => write!(buf, " --{long}")?, + (None, Some(short)) => write!(buf, " -{short}")?, + (None, None) => continue, + } + arg_type(name, arg, buf)?; + writeln!(buf)?; + } + writeln!(buf, " ]")?; + writeln!(buf)?; + + // argument completions + for arg in cmd.get_arguments() { + let possible_values = arg.get_possible_values(); + if possible_values.is_empty() { + continue; + } + writeln!(buf, " def \"complete-value {name} {}\" [] {{", arg.get_id().as_str())?; + writeln!(buf, " [")?; + for possible_value in &possible_values { + write!(buf, " {{ value: \"{}\"", possible_value.get_name())?; + if let Some(help) = possible_value.get_help() { + write!(buf, ", description: \"{help}\"")?; + } + writeln!(buf, " }},")?; + } + writeln!(buf, " ]")?; + writeln!(buf, " }}")?; + writeln!(buf)?; + } + + // subcommand completion + if cmd.get_subcommands().count() != 0 { + writeln!(buf, " def \"complete-subcommand {name}\" [] {{")?; + writeln!(buf, " [")?; + for subcommand in cmd.get_subcommands() { + write!(buf, " {{ value: \"{}\"", subcommand.get_name())?; + if let Some(about) = subcommand.get_about() { + write!(buf, ", description: \"{about}\"")?; + } + writeln!(buf, " }},")?; + } + writeln!(buf, " ]")?; + writeln!(buf, " }}")?; + writeln!(buf)?; + } + + for subcommand in cmd.get_subcommands() { + generate_subcommand(subcommand, buf)?; + } + Ok(()) +} + +fn arg_type(cmd_name: &str, arg: &clap::Arg, buf: &mut dyn Write) -> eyre::Result<()> { + use clap::ValueHint; + let takes_values = arg.get_num_args().map(|r| r.takes_values()).unwrap_or_default(); + if takes_values { + let type_name = match arg.get_value_hint() { + ValueHint::Unknown => "string", + ValueHint::Other => "string", + ValueHint::AnyPath => "path", + ValueHint::FilePath => "path", + ValueHint::DirPath => "path", + ValueHint::ExecutablePath => "path", + ValueHint::CommandName => "string", + ValueHint::CommandString => "path", + ValueHint::CommandWithArguments => "string", + ValueHint::Username => "string", + ValueHint::Hostname => "string", + ValueHint::Url => "string", + ValueHint::EmailAddress => "string", + _ => "string", + }; + + write!(buf, ": {type_name}")?; + } + + let possible_values = arg.get_possible_values(); + if !possible_values.is_empty() { + write!(buf, "@\"complete-value {cmd_name} {}\"", arg.get_id().as_str())?; + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9e52800..e3936c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod user; mod version; mod whoami; mod wiki; +mod completion; pub const USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), @@ -50,6 +51,7 @@ pub enum Command { Release(release::ReleaseCommand), User(user::UserCommand), Version(version::VersionCommand), + Completion(completion::CompletionCommand), } #[tokio::main] @@ -72,6 +74,7 @@ async fn main() -> eyre::Result<()> { Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?, Command::User(subcommand) => subcommand.run(&mut keys, host_name).await?, Command::Version(command) => command.run().await?, + Command::Completion(subcommand) => subcommand.run(), } keys.save().await?;