From efa5ee0dc91373bddee543d949fd4da44d852036 Mon Sep 17 00:00:00 2001 From: Fotis Gimian Date: Tue, 31 Dec 2024 11:54:08 +1100 Subject: [PATCH] Allow for man page and completion generation at runtime and add Nushell support --- Cargo.lock | 11 ++ Cargo.toml | 6 +- RELEASE-CHECKLIST.md | 2 +- src/cli.rs | 279 ++++++---------------------------- src/main.rs | 353 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 415 insertions(+), 236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 458e4574..98b2d812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,16 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "315902e790cc6e5ddd20cbd313c1d0d49db77f191e149f96397230fb82a17677" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" version = "4.5.18" @@ -2515,6 +2525,7 @@ dependencies = [ "chardetng", "clap", "clap_complete", + "clap_complete_nushell", "cookie_store 0.20.0", "digest_auth", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 0cadc32d..6af40343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ anyhow = "1.0.38" brotli = { version = "3.3.0", default-features = false, features = ["std"] } chardetng = "0.1.15" clap = { version = "4.4", features = ["derive", "wrap_help", "string"] } -clap_complete = { version = "4.4", optional = true } +clap_complete = "4.4" cookie_store = { version = "0.20.0", features = ["preserve_order"] } digest_auth = "0.3.0" dirs = "5.0" @@ -38,7 +38,7 @@ once_cell = "1.8.0" os_display = "0.1.3" pem = "3.0" regex-lite = "0.1.5" -roff = { version = "0.2.1", optional = true } +roff = "0.2.1" rpassword = "7.2.0" serde = { version = "1.0", features = ["derive"] } serde-transcode = "1.1.1" @@ -57,6 +57,7 @@ log = "0.4.21" # The rustls version number should be kept in sync with hyper/reqwest. rustls = { version = "0.23.14", optional = true, default-features = false, features = ["logging"] } tracing = { version = "0.1.41", default-features = false, features = ["log"] } +clap_complete_nushell = "4.5.4" [dependencies.reqwest] version = "0.12.3" @@ -101,7 +102,6 @@ network-interface = ["dep:network-interface"] online-tests = [] ipv6-tests = [] -man-completion-gen = ["clap_complete", "roff"] [package.metadata.cross.build.env] passthrough = ["CARGO_PROFILE_RELEASE_LTO"] diff --git a/RELEASE-CHECKLIST.md b/RELEASE-CHECKLIST.md index d8b215e8..dd073598 100644 --- a/RELEASE-CHECKLIST.md +++ b/RELEASE-CHECKLIST.md @@ -6,7 +6,7 @@ - Bump up the version in `Cargo.toml` and run `cargo check` to update `Cargo.lock`. - Run the following to update man pages and shell-completion files. ```sh - cargo run --all-features -- generate-completions completions && cargo run --all-features -- generate-manpages doc + cargo run -- --generate complete-all --generate-to completions && cargo run -- --generate man-pages --generate-to doc ``` - Commit changes and push them to remote. - Add git tag e.g `git tag v0.9.0`. diff --git a/src/cli.rs b/src/cli.rs index a6b37d59..6de7c591 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -367,10 +367,22 @@ Example: --print=Hb" #[clap(long)] pub curl_long: bool, + /// Generate shell completions or man pages. + #[arg(long, value_name = "KIND")] + pub generate: Option, + + /// Save generated shell completions or man pages to DIRECTORY instead of stdout. + #[clap(long, value_name = "DIRECTORY", requires = "generate")] + pub generate_to: Option, + /// Print help. #[clap(long, action = ArgAction::HelpShort)] pub help: Option, + /// Print long help. + #[clap(long, action = ArgAction::HelpLong)] + pub long_help: Option, + /// The request URL, preceded by an optional HTTP method. /// /// If the method is omitted, it will default to GET, or to POST @@ -381,8 +393,13 @@ Example: --print=Hb" /// /// A leading colon works as shorthand for localhost. ":8000" is equivalent /// to "localhost:8000", and ":/path" is equivalent to "localhost/path". - #[clap(value_name = "[METHOD] URL")] - raw_method_or_url: String, + #[clap( + value_name = "[METHOD] URL", + required_unless_present = "generate", + conflicts_with = "generate", + conflicts_with = "generate_to" + )] + raw_method_or_url: Option, /// Optional key-value pairs to be included in the request. /// @@ -480,21 +497,28 @@ impl Cli { let matches = app.try_get_matches_from_mut(iter)?; let mut cli = Self::from_arg_matches(&matches)?; - match cli.raw_method_or_url.as_str() { - "help" => { - // opt-out of clap's auto-generated possible values help for --pretty - // as we already list them in the long_help - app = app.mut_arg("pretty", |a| a.hide_possible_values(true)); + app.get_bin_name() + .and_then(|name| name.split('.').next()) + .unwrap_or("xh") + .clone_into(&mut cli.bin_name); - app.print_long_help().unwrap(); - safe_exit(); + if let Some(generate) = cli.generate { + if generate == Generate::CompleteAll && cli.generate_to.is_none() { + return Err(app.error( + clap::error::ErrorKind::MissingRequiredArgument, + "The --generate-to option is required when generating all completions.", + )); } - "generate-completions" => return Err(generate_completions(app, cli.raw_rest_args)), - "generate-manpages" => return Err(generate_manpages(app, cli.raw_rest_args)), - _ => {} + + return Ok(cli); } + + let Some(mut raw_method_or_url) = cli.raw_method_or_url.clone() else { + unreachable!() + }; + let mut rest_args = mem::take(&mut cli.raw_rest_args).into_iter(); - let raw_url = match parse_method(&cli.raw_method_or_url) { + let raw_url = match parse_method(&raw_method_or_url) { Some(method) => { cli.method = Some(method); rest_args.next().ok_or_else(|| { @@ -506,7 +530,7 @@ impl Cli { } None => { cli.method = None; - mem::take(&mut cli.raw_method_or_url) + mem::take(&mut raw_method_or_url) } }; for request_item in rest_args { @@ -517,11 +541,6 @@ impl Cli { ); } - app.get_bin_name() - .and_then(|name| name.split('.').next()) - .unwrap_or("xh") - .clone_into(&mut cli.bin_name); - if matches!(cli.bin_name.as_str(), "https" | "xhs" | "xhttps") { cli.https = true; } @@ -744,209 +763,6 @@ fn construct_url( Ok(url) } -#[cfg(feature = "man-completion-gen")] -// This signature is a little weird: we either return an error or don't return at all -fn generate_completions(mut app: clap::Command, rest_args: Vec) -> clap::error::Error { - let bin_name = app.get_bin_name().unwrap().to_string(); - if rest_args.len() != 1 { - return app.error( - clap::error::ErrorKind::WrongNumberOfValues, - "Usage: xh generate-completions ", - ); - } - - for &shell in clap_complete::Shell::value_variants() { - // Elvish complains about multiple deprecations and these don't seem to work - if shell != clap_complete::Shell::Elvish { - clap_complete::generate_to(shell, &mut app, &bin_name, &rest_args[0]).unwrap(); - } - } - safe_exit(); -} - -#[cfg(feature = "man-completion-gen")] -fn generate_manpages(mut app: clap::Command, rest_args: Vec) -> clap::error::Error { - use roff::{bold, italic, roman, Roff}; - use time::OffsetDateTime as DateTime; - - if rest_args.len() != 1 { - return app.error( - clap::error::ErrorKind::WrongNumberOfValues, - "Usage: xh generate-manpages ", - ); - } - - let items: Vec<_> = app.get_arguments().filter(|i| !i.is_hide_set()).collect(); - - let mut request_items_roff = Roff::new(); - let request_items = items - .iter() - .find(|opt| opt.get_id() == "raw_rest_args") - .unwrap(); - let request_items_help = request_items - .get_long_help() - .or_else(|| request_items.get_help()) - .expect("request_items is missing help") - .to_string(); - - // replace the indents in request_item help with proper roff controls - // For example: - // - // ``` - // normal help normal help - // normal help normal help - // - // request-item-1 - // help help - // - // request-item-2 - // help help - // - // normal help normal help - // ``` - // - // Should look like this with roff controls - // - // ``` - // normal help normal help - // normal help normal help - // .RS 12 - // .TP - // request-item-1 - // help help - // .TP - // request-item-2 - // help help - // .RE - // - // .RS - // normal help normal help - // .RE - // ``` - let lines: Vec<&str> = request_items_help.lines().collect(); - let mut rs = false; - for i in 0..lines.len() { - if lines[i].is_empty() { - let prev = lines[i - 1].chars().take_while(|&x| x == ' ').count(); - let next = lines[i + 1].chars().take_while(|&x| x == ' ').count(); - if prev != next && next > 0 { - if !rs { - request_items_roff.control("RS", ["8"]); - rs = true; - } - request_items_roff.control("TP", ["4"]); - } else if prev != next && next == 0 { - request_items_roff.control("RE", []); - request_items_roff.text(vec![roman("")]); - request_items_roff.control("RS", []); - } else { - request_items_roff.text(vec![roman(lines[i])]); - } - } else { - request_items_roff.text(vec![roman(lines[i].trim())]); - } - } - request_items_roff.control("RE", []); - - let mut options_roff = Roff::new(); - let non_pos_items = items - .iter() - .filter(|a| !a.is_positional()) - .collect::>(); - - for opt in non_pos_items { - let mut header = vec![]; - if let Some(short) = opt.get_short() { - header.push(bold(format!("-{}", short))); - } - if let Some(long) = opt.get_long() { - if !header.is_empty() { - header.push(roman(", ")); - } - header.push(bold(format!("--{}", long))); - } - if opt.get_action().takes_values() { - let value_name = &opt.get_value_names().unwrap(); - if opt.get_long().is_some() { - header.push(roman("=")); - } else { - header.push(roman(" ")); - } - - if opt.get_id() == "auth" { - header.push(italic("USER")); - header.push(roman("[")); - header.push(italic(":PASS")); - header.push(roman("] | ")); - header.push(italic("TOKEN")); - } else { - header.push(italic(value_name.join(" "))); - } - } - let mut body = vec![]; - - let mut help = opt - .get_long_help() - .or_else(|| opt.get_help()) - .expect("option is missing help") - .to_string(); - if !help.ends_with('.') { - help.push('.') - } - body.push(roman(help)); - - let possible_values = opt.get_possible_values(); - if !possible_values.is_empty() - && !opt.is_hide_possible_values_set() - && opt.get_id() != "pretty" - { - let possible_values_text = format!( - "\n\n[possible values: {}]", - possible_values - .iter() - .map(|v| v.get_name()) - .collect::>() - .join(", ") - ); - body.push(roman(possible_values_text)); - } - options_roff.control("TP", ["4"]); - options_roff.text(header); - options_roff.text(body); - } - - let mut manpage = fs::read_to_string(format!("{}/man-template.roff", rest_args[0])).unwrap(); - - let current_date = { - let (year, month, day) = DateTime::now_utc().date().to_calendar_date(); - format!("{}-{:02}-{:02}", year, u8::from(month), day) - }; - - manpage = manpage.replace("{{date}}", ¤t_date); - manpage = manpage.replace("{{version}}", app.get_version().unwrap()); - manpage = manpage.replace("{{request_items}}", request_items_roff.to_roff().trim()); - manpage = manpage.replace("{{options}}", options_roff.to_roff().trim()); - - fs::write(format!("{}/xh.1", rest_args[0]), manpage).unwrap(); - safe_exit(); -} - -#[cfg(not(feature = "man-completion-gen"))] -fn generate_completions(mut _app: clap::Command, _rest_args: Vec) -> clap::error::Error { - clap::Error::raw( - clap::error::ErrorKind::InvalidSubcommand, - "generate-completions requires enabling man-completion-gen feature\n", - ) -} - -#[cfg(not(feature = "man-completion-gen"))] -fn generate_manpages(mut _app: clap::Command, _rest_args: Vec) -> clap::error::Error { - clap::Error::raw( - clap::error::ErrorKind::InvalidSubcommand, - "generate-manpages requires enabling man-completion-gen feature\n", - ) -} - #[derive(Default, ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] pub enum AuthType { #[default] @@ -1353,6 +1169,18 @@ pub enum HttpVersion { Http2PriorKnowledge, } +#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum Generate { + CompleteBash, + CompleteElvish, + CompleteFish, + CompleteNushell, + CompletePowershell, + CompleteZsh, + CompleteAll, + ManPages, +} + /// HTTPie uses Python's str.decode(). That one's very accepting of different spellings. /// encoding_rs is not. /// @@ -1407,13 +1235,6 @@ fn parse_encoding(encoding: &str) -> anyhow::Result<&'static Encoding> { )) } -/// Based on the function used by clap to abort -fn safe_exit() -> ! { - let _ = std::io::stdout().lock().flush(); - let _ = std::io::stderr().lock().flush(); - std::process::exit(0); -} - fn long_version() -> &'static str { concat!(env!("CARGO_PKG_VERSION"), "\n", env!("XH_FEATURES")) } diff --git a/src/main.rs b/src/main.rs index 68526a22..76de15ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,16 +17,19 @@ mod to_curl; mod utils; mod vendored; -use std::env; use std::fs::File; use std::io::{self, IsTerminal, Read}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process; use std::str::FromStr; use std::sync::Arc; +use std::{env, fs}; use anyhow::{anyhow, Context, Result}; +use clap::ValueEnum as _; +use clap_complete::{Generator, Shell}; +use clap_complete_nushell::Nushell; use cookie_store::{CookieStore, RawCookie}; use redirect::RedirectFollower; use reqwest::blocking::Client; @@ -51,6 +54,8 @@ use crate::vendored::reqwest_cookie_store; #[cfg(not(any(feature = "native-tls", feature = "rustls")))] compile_error!("Either native-tls or rustls feature must be enabled!"); +const MAN_TEMPLATE: &str = include_str!("../doc/man-template.roff"); + fn get_user_agent() -> &'static str { if test_mode() { // Hard-coded user agent for the benefit of tests @@ -62,6 +67,109 @@ fn get_user_agent() -> &'static str { fn main() { let args = Cli::parse(); + let bin_name = args.bin_name.clone(); + + if let Some(generate) = args.generate { + let mut cmd = Cli::into_app(); + + let result = match generate { + cli::Generate::CompleteBash => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Shell::Bash), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Shell::Bash); + Ok(()) + } + } + cli::Generate::CompleteElvish => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Shell::Elvish), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Shell::Elvish); + Ok(()) + } + } + cli::Generate::CompleteFish => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Shell::Fish), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Shell::Fish); + Ok(()) + } + } + cli::Generate::CompleteNushell => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Nushell), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Nushell); + Ok(()) + } + } + cli::Generate::CompletePowershell => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Shell::PowerShell), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Shell::PowerShell); + Ok(()) + } + } + cli::Generate::CompleteZsh => { + if let Some(generate_to) = args.generate_to { + generate_completions_to_directory( + &bin_name, + &mut cmd, + Some(Shell::Zsh), + &generate_to, + ) + } else { + generate_completions_to_stdout(&bin_name, &mut cmd, Shell::Zsh); + Ok(()) + } + } + cli::Generate::CompleteAll => { + if let Some(generate_to) = args.generate_to { + generate_all_completions_to_directory(&bin_name, &mut cmd, &generate_to) + } else { + unreachable!() + } + } + cli::Generate::ManPages => generate_manpages(&cmd, args.generate_to.as_deref()), + }; + + match result { + Ok(()) => process::exit(0), + Err(err) => { + log::debug!("{err:#?}"); + eprintln!("{bin_name}: error: {err:?}"); + process::exit(1); + } + } + } if args.debug { setup_backtraces(); @@ -73,7 +181,6 @@ fn main() { log::debug!("{args:#?}"); let native_tls = args.native_tls; - let bin_name = args.bin_name.clone(); match run(args) { Ok(exit_code) => { @@ -653,3 +760,243 @@ fn setup_backtraces() { std::env::set_var("RUST_BACKTRACE", "1"); } } + +fn generate_completions_to_stdout( + bin_name: &str, + cmd: &mut clap::Command, + generator: G, +) { + clap_complete::generate(generator, cmd, bin_name, &mut io::stdout()); +} + +fn generate_completions_to_directory( + bin_name: &str, + cmd: &mut clap::Command, + generator: Option, + out_directory: impl AsRef, +) -> Result<()> { + if let Some(generator) = generator { + clap_complete::generate_to(generator, cmd, bin_name, out_directory.as_ref()).with_context( + || { + format!( + "Failed to generate completions to directory: {}", + out_directory.as_ref().display() + ) + }, + )?; + } + + for &shell in clap_complete::Shell::value_variants() { + clap_complete::generate_to(shell, cmd, bin_name, out_directory.as_ref()).with_context( + || { + format!( + "Failed to generate completions to directory: {}", + out_directory.as_ref().display() + ) + }, + )?; + } + + clap_complete::generate_to(Nushell, cmd, bin_name, out_directory.as_ref()).with_context( + || { + format!( + "Failed to generate completions to directory: {}", + out_directory.as_ref().display() + ) + }, + )?; + + Ok(()) +} + +fn generate_all_completions_to_directory( + bin_name: &str, + cmd: &mut clap::Command, + out_directory: impl AsRef, +) -> Result<()> { + for &shell in clap_complete::Shell::value_variants() { + clap_complete::generate_to(shell, cmd, bin_name, out_directory.as_ref()).with_context( + || { + format!( + "Failed to generate completions to directory: {}", + out_directory.as_ref().display() + ) + }, + )?; + } + + clap_complete::generate_to(Nushell, cmd, bin_name, out_directory.as_ref()).with_context( + || { + format!( + "Failed to generate completions to directory: {}", + out_directory.as_ref().display() + ) + }, + )?; + + Ok(()) +} + +fn generate_manpages(cmd: &clap::Command, out_directory: Option>) -> Result<()> { + use roff::{bold, italic, roman, Roff}; + use time::OffsetDateTime as DateTime; + + let items: Vec<_> = cmd.get_arguments().filter(|i| !i.is_hide_set()).collect(); + + let mut request_items_roff = Roff::new(); + let request_items = items + .iter() + .find(|opt| opt.get_id() == "raw_rest_args") + .unwrap(); + let request_items_help = request_items + .get_long_help() + .or_else(|| request_items.get_help()) + .expect("request_items is missing help") + .to_string(); + + // replace the indents in request_item help with proper roff controls + // For example: + // + // ``` + // normal help normal help + // normal help normal help + // + // request-item-1 + // help help + // + // request-item-2 + // help help + // + // normal help normal help + // ``` + // + // Should look like this with roff controls + // + // ``` + // normal help normal help + // normal help normal help + // .RS 12 + // .TP + // request-item-1 + // help help + // .TP + // request-item-2 + // help help + // .RE + // + // .RS + // normal help normal help + // .RE + // ``` + let lines: Vec<&str> = request_items_help.lines().collect(); + let mut rs = false; + for i in 0..lines.len() { + if lines[i].is_empty() { + let prev = lines[i - 1].chars().take_while(|&x| x == ' ').count(); + let next = lines[i + 1].chars().take_while(|&x| x == ' ').count(); + if prev != next && next > 0 { + if !rs { + request_items_roff.control("RS", ["8"]); + rs = true; + } + request_items_roff.control("TP", ["4"]); + } else if prev != next && next == 0 { + request_items_roff.control("RE", []); + request_items_roff.text(vec![roman("")]); + request_items_roff.control("RS", []); + } else { + request_items_roff.text(vec![roman(lines[i])]); + } + } else { + request_items_roff.text(vec![roman(lines[i].trim())]); + } + } + request_items_roff.control("RE", []); + + let mut options_roff = Roff::new(); + let non_pos_items = items + .iter() + .filter(|a| !a.is_positional()) + .collect::>(); + + for opt in non_pos_items { + let mut header = vec![]; + if let Some(short) = opt.get_short() { + header.push(bold(format!("-{}", short))); + } + if let Some(long) = opt.get_long() { + if !header.is_empty() { + header.push(roman(", ")); + } + header.push(bold(format!("--{}", long))); + } + if opt.get_action().takes_values() { + let value_name = &opt.get_value_names().unwrap(); + if opt.get_long().is_some() { + header.push(roman("=")); + } else { + header.push(roman(" ")); + } + + if opt.get_id() == "auth" { + header.push(italic("USER")); + header.push(roman("[")); + header.push(italic(":PASS")); + header.push(roman("] | ")); + header.push(italic("TOKEN")); + } else { + header.push(italic(value_name.join(" "))); + } + } + let mut body = vec![]; + + let mut help = opt + .get_long_help() + .or_else(|| opt.get_help()) + .expect("option is missing help") + .to_string(); + if !help.ends_with('.') { + help.push('.') + } + body.push(roman(help)); + + let possible_values = opt.get_possible_values(); + if !possible_values.is_empty() + && !opt.is_hide_possible_values_set() + && opt.get_id() != "pretty" + { + let possible_values_text = format!( + "\n\n[possible values: {}]", + possible_values + .iter() + .map(|v| v.get_name()) + .collect::>() + .join(", ") + ); + body.push(roman(possible_values_text)); + } + options_roff.control("TP", ["4"]); + options_roff.text(header); + options_roff.text(body); + } + + let mut manpage = MAN_TEMPLATE.to_string(); + + let current_date = { + let (year, month, day) = DateTime::now_utc().date().to_calendar_date(); + format!("{}-{:02}-{:02}", year, u8::from(month), day) + }; + + manpage = manpage.replace("{{date}}", ¤t_date); + manpage = manpage.replace("{{version}}", cmd.get_version().unwrap()); + manpage = manpage.replace("{{request_items}}", request_items_roff.to_roff().trim()); + manpage = manpage.replace("{{options}}", options_roff.to_roff().trim()); + + if let Some(out_directory) = out_directory { + fs::write(out_directory.as_ref().join("xh.1"), manpage)?; + } else { + println!("{}", manpage); + } + + Ok(()) +}