From c95ff95157bfbd0e0ff9363f03eb51c4ca73d051 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Thu, 3 Oct 2024 08:35:16 -0400 Subject: [PATCH] Use dynamic shell completions This replaces the subcommand with an env var to enable completions --- CHANGELOG.md | 5 +- crates/cli/src/commands.rs | 1 - crates/cli/src/commands/completions.rs | 32 ------------ crates/cli/src/commands/history.rs | 28 +++++++++-- crates/cli/src/commands/request.rs | 18 +++++-- crates/cli/src/completions.rs | 50 +++++++++++++++++++ crates/cli/src/lib.rs | 18 ++++--- crates/core/src/collection.rs | 5 +- docs/src/SUMMARY.md | 1 + docs/src/install.md | 2 +- docs/src/troubleshooting/shell_completions.md | 46 +++++++++++++++++ src/main.rs | 1 + 12 files changed, 155 insertions(+), 52 deletions(-) delete mode 100644 crates/cli/src/commands/completions.rs create mode 100644 crates/cli/src/completions.rs create mode 100644 docs/src/troubleshooting/shell_completions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ff66dde9..e65f95c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- Add `slumber completions` subcommand - - Right now only static completions are available (e.g. subcommands). In the future I am to add dynamic completions based on your collection file (e.g. recipe and profile IDs) +- Add shell completions, accessed by enabling the `COMPLETE` environment variable + - For example, adding `COMPLETE=fish slumber | source` to your `fish.config` will enable completions for fish + - [See docs](https://slumber.lucaspickering.me/book/troubleshooting/shell_completions.html) for more info and a list of supported shells ## [2.1.0] - 2024-09-27 diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 6b0b82f6..a490672f 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -1,5 +1,4 @@ pub mod collections; -pub mod completions; pub mod generate; pub mod history; pub mod import; diff --git a/crates/cli/src/commands/completions.rs b/crates/cli/src/commands/completions.rs deleted file mode 100644 index af32ac11..00000000 --- a/crates/cli/src/commands/completions.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{Args, GlobalArgs, Subcommand, COMMAND_NAME}; -use anyhow::anyhow; -use clap::{CommandFactory, Parser}; -use clap_complete::Shell; -use std::{io, process::ExitCode}; - -/// Generate shell completions -#[derive(Clone, Debug, Parser)] -pub struct CompletionsCommand { - /// Shell type. Default to $SHELL - #[clap(long)] - shell: Option, -} - -impl Subcommand for CompletionsCommand { - async fn execute(self, _: GlobalArgs) -> anyhow::Result { - let shell = self - .shell - .or_else(Shell::from_env) - .ok_or_else(|| anyhow!("No shell provided and none detected"))?; - let mut command = Args::command(); - - clap_complete::generate( - shell, - &mut command, - COMMAND_NAME, - &mut io::stdout(), - ); - - Ok(ExitCode::SUCCESS) - } -} diff --git a/crates/cli/src/commands/history.rs b/crates/cli/src/commands/history.rs index 7b47dcf2..cf2ebc0c 100644 --- a/crates/cli/src/commands/history.rs +++ b/crates/cli/src/commands/history.rs @@ -1,6 +1,11 @@ -use crate::{util::HeaderDisplay, GlobalArgs, Subcommand}; +use crate::{ + completions::{complete_profile, complete_recipe}, + util::HeaderDisplay, + GlobalArgs, Subcommand, +}; use anyhow::anyhow; -use clap::Parser; +use clap::{Parser, ValueHint}; +use clap_complete::ArgValueCompleter; use dialoguer::console::Style; use slumber_core::{ collection::{CollectionFile, ProfileId, RecipeId}, @@ -25,16 +30,29 @@ enum HistorySubcommand { #[command(visible_alias = "ls")] List { /// Recipe to query for + #[clap(add = ArgValueCompleter::new(complete_recipe))] recipe: RecipeId, /// Profile to query for. If omitted, query for requests with no /// profile - #[clap(long = "profile", short)] + #[clap( + long = "profile", + short, + add = ArgValueCompleter::new(complete_profile), + )] profile: Option, }, - /// Print an entire request/response by ID - Get { request: RequestId }, + /// Print an entire request/response + Get { + // Disable completion for this arg. We could load all the request IDs + // from the DB, but that's not worth the effort since this is an + // unstable command still and people will rarely be typing an ID by + // hand, they'll typically just copy paste + /// ID of the request/response to print + #[clap(value_hint = ValueHint::Other)] + request: RequestId, + }, } impl Subcommand for HistoryCommand { diff --git a/crates/cli/src/commands/request.rs b/crates/cli/src/commands/request.rs index 4e2b872e..a767290a 100644 --- a/crates/cli/src/commands/request.rs +++ b/crates/cli/src/commands/request.rs @@ -1,6 +1,11 @@ -use crate::{util::HeaderDisplay, GlobalArgs, Subcommand}; +use crate::{ + completions::{complete_profile, complete_recipe}, + util::HeaderDisplay, + GlobalArgs, Subcommand, +}; use anyhow::{anyhow, Context}; -use clap::Parser; +use clap::{Parser, ValueHint}; +use clap_complete::ArgValueCompleter; use dialoguer::{Input, Password, Select as DialoguerSelect}; use indexmap::IndexMap; use itertools::Itertools; @@ -59,12 +64,17 @@ pub struct RequestCommand { #[derive(Clone, Debug, Parser)] pub struct BuildRequestCommand { /// ID of the recipe to render into a request + #[clap(add = ArgValueCompleter::new(complete_recipe))] recipe_id: RecipeId, /// ID of the profile to pull template values from. If omitted and the /// collection has default profile defined, use that profile. Otherwise, /// profile data will not be available. - #[clap(long = "profile", short)] + #[clap( + long = "profile", + short, + add = ArgValueCompleter::new(complete_profile), + )] profile: Option, /// List of key=value template field overrides @@ -72,6 +82,8 @@ pub struct BuildRequestCommand { long = "override", short = 'o', value_parser = parse_key_val::, + // There's no reasonable way of doing completions on this, so disable + value_hint = ValueHint::Other, )] overrides: Vec<(String, String)>, } diff --git a/crates/cli/src/completions.rs b/crates/cli/src/completions.rs new file mode 100644 index 00000000..a2da0e73 --- /dev/null +++ b/crates/cli/src/completions.rs @@ -0,0 +1,50 @@ +//! Shell completion utilities + +use clap_complete::CompletionCandidate; +use slumber_core::collection::{Collection, CollectionFile}; +use std::{ffi::OsStr, ops::Deref}; + +/// Provide completions for profile IDs +pub fn complete_profile(current: &OsStr) -> Vec { + let Ok(collection) = load_collection() else { + return Vec::new(); + }; + + get_candidates(collection.profiles.keys(), current) +} + +/// Provide completions for recipe IDs +pub fn complete_recipe(current: &OsStr) -> Vec { + let Ok(collection) = load_collection() else { + return Vec::new(); + }; + + get_candidates( + collection + .recipes + .iter() + // Include recipe IDs only. Folder IDs are never passed to the CLI + .filter_map(|(_, node)| Some(&node.recipe()?.id)), + current, + ) +} + +fn load_collection() -> anyhow::Result { + // For now we just lean on the default collection paths. In the future we + // should be able to look for a --file arg in the command and use that path + let path = CollectionFile::try_path(None, None)?; + Collection::load(&path) +} + +fn get_candidates<'a, T: 'a + Deref>( + iter: impl Iterator, + current: &OsStr, +) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; + // Only include IDs prefixed by the input we've gotten so far + iter.filter(|value| value.starts_with(current)) + .map(|value| CompletionCandidate::new(value.as_str())) + .collect() +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index fc6afbf9..a5108d32 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -8,14 +8,16 @@ //! do so at your own risk of breakage. mod commands; +mod completions; mod util; use crate::commands::{ - collections::CollectionsCommand, completions::CompletionsCommand, - generate::GenerateCommand, history::HistoryCommand, import::ImportCommand, - new::NewCommand, request::RequestCommand, show::ShowCommand, + collections::CollectionsCommand, generate::GenerateCommand, + history::HistoryCommand, import::ImportCommand, new::NewCommand, + request::RequestCommand, show::ShowCommand, }; -use clap::Parser; +use clap::{CommandFactory, Parser}; +use clap_complete::CompleteEnv; use std::{path::PathBuf, process::ExitCode}; const COMMAND_NAME: &str = "slumber"; @@ -37,6 +39,12 @@ pub struct Args { } impl Args { + /// Check if we're in shell completion mode, which is set via the `COMPLETE` + /// env var. If so, this will print completions then exit the process + pub fn complete() { + CompleteEnv::with_factory(Args::command).complete(); + } + /// Alias for [clap::Parser::parse] pub fn parse() -> Self { ::parse() @@ -57,7 +65,6 @@ pub struct GlobalArgs { #[derive(Clone, Debug, clap::Subcommand)] pub enum CliCommand { Collections(CollectionsCommand), - Completions(CompletionsCommand), Generate(GenerateCommand), History(HistoryCommand), Import(ImportCommand), @@ -71,7 +78,6 @@ impl CliCommand { pub async fn execute(self, global: GlobalArgs) -> anyhow::Result { match self { Self::Collections(command) => command.execute(global).await, - Self::Completions(command) => command.execute(global).await, Self::Generate(command) => command.execute(global).await, Self::History(command) => command.execute(global).await, Self::Import(command) => command.execute(global).await, diff --git a/crates/core/src/collection.rs b/crates/core/src/collection.rs index 5003873c..626bcb96 100644 --- a/crates/core/src/collection.rs +++ b/crates/core/src/collection.rs @@ -11,7 +11,7 @@ pub use cereal::HasId; pub use models::*; pub use recipe_tree::*; -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use itertools::Itertools; use std::{ env, @@ -159,7 +159,8 @@ async fn load_collection(path: PathBuf) -> anyhow::Result { // tokio::fs for this but that just uses std::fs underneath anyway. task::spawn_blocking(move || Collection::load(&path)) .await - .expect("TODO") + // This error only occurs if the task panics + .context("Error parsing collection")? } #[cfg(test)] diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 4f886ee9..b978fec5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -45,4 +45,5 @@ - [Logs](./troubleshooting/logs.md) - [Lost Request History](./troubleshooting/lost_history.md) +- [Shell Completions](./troubleshooting/shell_completions.md) - [TLS Certificate Errors](./troubleshooting/tls.md) diff --git a/docs/src/install.md b/docs/src/install.md index 02dbec96..2ab129f6 100644 --- a/docs/src/install.md +++ b/docs/src/install.md @@ -1,3 +1,3 @@ # Install -See [installation instructions](/artifacts) +See [installation instructions](/artifacts). Optionally, after installation you can [enable shell completions](./troubleshooting/shell_completions.md). diff --git a/docs/src/troubleshooting/shell_completions.md b/docs/src/troubleshooting/shell_completions.md new file mode 100644 index 00000000..98359f2e --- /dev/null +++ b/docs/src/troubleshooting/shell_completions.md @@ -0,0 +1,46 @@ +# Shell Completions + +Slumber provides tab completions for most shells. For the full list of supported shells, [see the clap docs](https://docs.rs/clap_complete/latest/clap_complete/aot/enum.Shell.html). + +> Note: Slumber uses clap's native shell completions, which are still experimental. [This issue](https://github.com/clap-rs/clap/issues/3166) outlines the remaining work to be done. + +To source your completions: + +**WARNING:** We recommend re-sourcing your completions on upgrade. +These completions work by generating shell code that calls into `your_program` while completing. +That interface is unstable and a mismatch between the shell code and `your_program` may result +in either invalid completions or no completions being generated. + +For this reason, we recommend generating the shell code anew on shell startup so that it is +"self-correcting" on shell launch, rather than writing the generated completions to a file. + +## Bash + +```bash +echo "source <(COMPLETE=bash slumber)" >> ~/.bashrc +``` + +## Elvish + +```elvish +echo "eval (E:COMPLETE=elvish slumber | slurp)" >> ~/.elvish/rc.elv +``` + +## Fish + +```fish +echo "source (COMPLETE=fish slumber | psub)" >> ~/.config/fish/config.fish +``` + +## Powershell + +```powershell +echo "COMPLETE=powershell slumber | Invoke-Expression" >> $PROFILE +``` + +## Zsh + +````zsh +echo "source <(COMPLETE=zsh slumber)" >> ~/.zshrc +``` +```` diff --git a/src/main.rs b/src/main.rs index 6097d221..c63744c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use tracing_subscriber::{filter::Targets, fmt::format::FmtSpan, prelude::*}; #[tokio::main] async fn main() -> anyhow::Result { // Global initialization + Args::complete(); // If COMPLETE var is enabled, process will stop here let args = Args::parse(); initialize_tracing(args.subcommand.is_some());